Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
102 changes: 102 additions & 0 deletions graphql/server-test/__fixtures__/seed/cors/schema.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
-- Minimal reader contracts for the real server/loader pipeline. This fixture
-- tests runtime composition, not constructive-db's routing or policy SQL.
CREATE SCHEMA cors_test;
CREATE SCHEMA function_resolution;
CREATE SCHEMA routing_public;
CREATE SCHEMA app_scope;
GRANT USAGE ON SCHEMA cors_test TO anonymous, authenticated;

CREATE SEQUENCE cors_test.auth_calls MINVALUE 0 START 0;
CREATE SEQUENCE cors_test.graphql_calls MINVALUE 0 START 0;
SELECT setval('cors_test.auth_calls', 0, true), setval('cors_test.graphql_calls', 0, true);
GRANT USAGE ON ALL SEQUENCES IN SCHEMA cors_test TO anonymous, authenticated;

CREATE FUNCTION cors_test.authenticate(token text)
RETURNS TABLE (user_id uuid) LANGUAGE plpgsql AS $$
BEGIN
PERFORM nextval('cors_test.auth_calls');
RETURN QUERY SELECT 'bbbbbbbb-0000-4000-8000-000000000001'::uuid
WHERE token = 'cors-test-session';
END;
$$;

CREATE FUNCTION cors_test.touch() RETURNS text LANGUAGE plpgsql VOLATILE AS $$
BEGIN
PERFORM nextval('cors_test.graphql_calls');
RETURN current_user;
END;
$$;

CREATE FUNCTION function_resolution.routing_planes()
RETURNS TABLE (database_id uuid, schema_name text, resolver_schema_name text, resolver_function_name text)
LANGUAGE sql AS $$
SELECT 'aaaaaaaa-0000-4000-8000-000000000001'::uuid,
'routing_public', 'routing_public', 'resolve_route';
$$;

CREATE FUNCTION routing_public.resolve_route(host text, path text, method text)
RETURNS TABLE (route_binding_id uuid, target_module text, target_source_id uuid, resolved_config jsonb)
LANGUAGE sql AS $$
SELECT api_id, 'api', api_id, jsonb_build_object(
'api_id', api_id,
'database_id', 'aaaaaaaa-0000-4000-8000-000000000001',
'role_name', 'authenticated', 'anon_role', 'anonymous',
'is_public', true, 'schemas', ARRAY['cors_test'])
FROM (VALUES
('a.cors.example', 'aaaaaaaa-0000-4000-8000-000000000011'::uuid),
('b.cors.example', 'aaaaaaaa-0000-4000-8000-000000000012'::uuid),
('empty.cors.example', 'aaaaaaaa-0000-4000-8000-000000000013'::uuid),
('broken.cors.example', 'aaaaaaaa-0000-4000-8000-000000000014'::uuid)
) AS routes(hostname, api_id) WHERE hostname = host;
$$;

CREATE FUNCTION function_resolution.cors_origins(database_id uuid, api_id uuid)
RETURNS TABLE (allowed_origins text[]) LANGUAGE plpgsql AS $$
BEGIN
IF api_id = 'aaaaaaaa-0000-4000-8000-000000000014'::uuid THEN
RAISE EXCEPTION 'CORS metadata unavailable';
END IF;
RETURN QUERY SELECT CASE api_id
WHEN 'aaaaaaaa-0000-4000-8000-000000000011'::uuid THEN ARRAY['https://a.client.example']
WHEN 'aaaaaaaa-0000-4000-8000-000000000012'::uuid THEN ARRAY['https://b.client.example']
ELSE ARRAY[]::text[] END;
END;
$$;

CREATE FUNCTION function_resolution.tenant_auth_entry(database_id uuid)
RETURNS TABLE (authenticate_schema text, role_schema text, authenticate_function text,
authenticate_strict_function text, current_role_function text, current_role_id_function text,
current_user_agent_function text, current_ip_address_function text)
LANGUAGE sql AS $$ SELECT 'cors_test', 'cors_test', 'authenticate', 'authenticate',
NULL::text, NULL::text, NULL::text, NULL::text; $$;

CREATE FUNCTION function_resolution.database_features(database_id uuid, api_id uuid)
RETURNS TABLE (enable_aggregates boolean, enable_postgis boolean, enable_search boolean,
enable_direct_uploads boolean, enable_presigned_uploads boolean, enable_many_to_many boolean,
enable_connection_filter boolean, enable_ltree boolean, enable_llm boolean,
enable_realtime boolean, enable_bulk boolean, enable_i18n boolean)
LANGUAGE sql AS $$ SELECT false, false, false, false, false, false,
false, false, false, false, false, false; $$;

CREATE FUNCTION function_resolution.pubkey_entry(database_id uuid)
RETURNS TABLE (schema_name text, crypto_network text, sign_up_with_key text,
sign_in_request_challenge text, sign_in_record_failure text, sign_in_with_challenge text)
LANGUAGE sql AS $$ SELECT NULL::text, NULL::text, NULL::text, NULL::text, NULL::text, NULL::text WHERE false; $$;

CREATE FUNCTION function_resolution.webauthn_entry(database_id uuid)
RETURNS TABLE (schema_name text, credentials_schema text, sessions_schema text,
session_secrets_schema text, rp_id text, rp_name text, origin_allowlist text[],
attestation_type text, require_user_verification boolean, resident_key text, challenge_expiry_seconds integer)
LANGUAGE sql AS $$ SELECT NULL::text, NULL::text, NULL::text, NULL::text, NULL::text,
NULL::text, NULL::text[], NULL::text, NULL::boolean, NULL::text, NULL::integer WHERE false; $$;

CREATE FUNCTION function_resolution.database_standing(database_id uuid)
RETURNS TABLE (suspended_at timestamptz, suspended_reason text)
LANGUAGE sql AS $$ SELECT NULL::timestamptz, NULL::text; $$;

CREATE FUNCTION function_resolution.request_protection(database_id uuid, api_id uuid)
RETURNS TABLE (db_max_query_depth integer)
LANGUAGE sql AS $$ SELECT 20; $$;

CREATE FUNCTION app_scope.platform_database_id() RETURNS uuid
LANGUAGE sql AS $$ SELECT 'aaaaaaaa-0000-4000-8000-000000000099'::uuid; $$;
199 changes: 199 additions & 0 deletions graphql/server-test/__tests__/cors.integration.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,199 @@
import { createDefaultRegistry } from '@constructive-io/express-context';
import { Server } from '@constructive-io/graphql-server';
import { graphileCache } from 'graphile-cache';
import path from 'path';
import type { PgTestClient } from 'pgsql-test/test-client';
import type supertest from 'supertest';

import { getConnections, seed } from '../src';

jest.setTimeout(60000);

const HOST = 'a.cors.example';
const ORIGIN = 'https://a.client.example';
const OTHER_ORIGIN = 'https://b.client.example';
const SESSION = 'constructive_session=cors-test-session';
const MUTATION = { query: 'mutation { touch(input: {}) { result } }' };

// Exercise the production Server composition, including real API resolution,
// metadata loaders, authentication, CSRF and GraphQL. Only the database reader
// contracts are supplied by the local SQL fixture; no middleware is mocked.
describe.each(['', 'https://fallback.example', '*'])('server CORS (fallback %j)', (fallback) => {
let pg: PgTestClient;
let db: PgTestClient;
let request: supertest.Agent;
let teardown: (() => Promise<void>) | undefined;
const previousOrigin = process.env.SERVER_ORIGIN;
const registry = createDefaultRegistry();

beforeAll(async () => {
process.env.SERVER_ORIGIN = fallback;
({ pg, db, request, teardown } = await getConnections({
schemas: ['cors_test'],
server: { useRouting: true, api: { isPublic: true, metaSchemas: ['routing_public'] } }
}, [seed.sqlfile([path.join(__dirname, '../__fixtures__/seed/cors/schema.sql')])]));
});

afterAll(async () => {
await teardown?.();
if (previousOrigin === undefined) delete process.env.SERVER_ORIGIN;
else process.env.SERVER_ORIGIN = previousOrigin;
registry.invalidate();
});

beforeEach(async () => {
await Server.closeCaches();
registry.invalidate();
await pg.beforeEach();
await db.beforeEach();
});

afterEach(async () => {
await db.afterEach();
await pg.afterEach();
await Server.closeCaches();
registry.invalidate();
});

const counts = async () => (await pg.one<{ auth: string; graphql: string }>(`
SELECT (SELECT last_value FROM cors_test.auth_calls) AS auth,
(SELECT last_value FROM cors_test.graphql_calls) AS graphql
`));

const preflight = (origin?: string, host = HOST, url = '/graphql') => {
const req = request.options(url).set('Host', host)
.set('Access-Control-Request-Method', 'POST')
.set('Access-Control-Request-Headers', 'content-type,authorization,x-csrf-token');
return origin === undefined ? req : req.set('Origin', origin);
};

const expectNoCors = (res: supertest.Response) => {
expect(res.headers['access-control-allow-origin']).toBeUndefined();
expect(res.headers['access-control-allow-credentials']).toBeUndefined();
expect(res.headers['access-control-allow-methods']).toBeUndefined();
expect(res.headers['access-control-allow-headers']).toBeUndefined();
};

it('resolves scoped metadata before preflight and reflects the allowed origin', async () => {
const before = await counts();
const builds = graphileCache.size;
// Even a credential-bearing OPTIONS must not invoke authentication.
const res = await preflight(ORIGIN).set('Authorization', 'Bearer invalid');
expect(res.status).toBe(200);
expect(res.text).toBe('');
expect(res.headers['access-control-allow-origin']).toBe(ORIGIN);
expect(res.headers['access-control-allow-credentials']).toBe('true');
expect(res.headers['access-control-allow-methods']).toContain('POST');
expect(res.headers['access-control-allow-headers']).toBe('content-type,authorization,x-csrf-token');
expect(res.headers.vary).toContain('Origin');
expect(res.headers.vary).toContain('Access-Control-Request-Headers');
expect(res.headers['set-cookie']).toBeUndefined();
expect(await counts()).toEqual(before);
expect(graphileCache.size).toBe(builds);
});

it.each([undefined, 'null', 'not-an-origin', 'https://attacker.example'])('ends OPTIONS for origin %j without auth or GraphQL', async (origin) => {
const before = await counts();
const builds = graphileCache.size;
const res = await preflight(origin).set('Cookie', SESSION);
expect(res.status).toBe(200);
expect(res.text).toBe('');
if (fallback === '*') {
expect(res.headers['access-control-allow-origin']).toBe(origin);
} else {
expectNoCors(res);
}
expect(res.headers['set-cookie']).toBeUndefined();
expect(await counts()).toEqual(before);
expect(graphileCache.size).toBe(builds);
});

it('keeps API origin lists separate across warm requests', async () => {
const a = await preflight(ORIGIN);
const b = await preflight(OTHER_ORIGIN, 'b.cors.example');
const denied = await preflight(ORIGIN, 'b.cors.example');
expect(a.headers['access-control-allow-origin']).toBe(ORIGIN);
expect(b.headers['access-control-allow-origin']).toBe(OTHER_ORIGIN);
expect(denied.status).toBe(200);
if (fallback === '*') expect(denied.headers['access-control-allow-origin']).toBe(ORIGIN);
else expectNoCors(denied);
});

it.each(['/graphql', '/fn/example', '/v1/threads'])('ends preflight before downstream middleware on %s', async (url) => {
const before = await counts();
const res = await preflight(OTHER_ORIGIN, HOST, url).set('Authorization', 'Bearer invalid');
expect(res.status).toBe(200);
expect(res.text).toBe('');
expect(await counts()).toEqual(before);
});

it.each([['unknown.cors.example', 404], ['broken.cors.example', 500]] as const)(
'fails closed for %s even with a matching fallback', async (host, status) => {
const before = await counts();
const origin = fallback && fallback !== '*' ? fallback : ORIGIN;
const res = await preflight(origin, host);
expect(res.status).toBe(status);
expectNoCors(res);
const actual = await request.post('/graphql').set('Host', host).set('Origin', origin).send(MUTATION);
expect(actual.status).toBe(status);
expectNoCors(actual);
expect(await counts()).toEqual(before);
}
);

it('preserves localhost and explicit fallback behavior when the API has no origins', async () => {
const local = await preflight('http://localhost:3000', 'empty.cors.example');
expect(local.status).toBe(200);
expect(local.headers['access-control-allow-origin']).toBe('http://localhost:3000');
const origin = 'https://fallback.example';
const res = await preflight(origin, 'empty.cors.example');
expect(res.status).toBe(200);
if (fallback) expect(res.headers['access-control-allow-origin']).toBe(origin);
else expectNoCors(res);
});

it('returns scoped CORS headers on an actual authentication refusal', async () => {
const before = await counts();
const res = await request.post('/graphql').set('Host', HOST).set('Origin', ORIGIN)
.set('Authorization', 'Bearer invalid').send(MUTATION);
expect(res.body.errors[0].extensions.code).toBe('UNAUTHENTICATED');
expect(res.headers['access-control-allow-origin']).toBe(ORIGIN);
expect((await counts()).graphql).toBe(before.graphql);
});

it.each([ORIGIN, OTHER_ORIGIN])('still enforces CSRF for an actual cookie request from %s', async (origin) => {
const before = await counts();
const res = await request.post('/graphql').set('Host', HOST).set('Origin', origin)
.set('Cookie', SESSION).send(MUTATION);
expect(res.status).toBe(403);
expect(res.body.error.code).toBe('CSRF_TOKEN_MISSING');
expect((await counts()).graphql).toBe(before.graphql);
});

it.each(['bearer', 'cookie', 'anonymous'])('executes actual %s requests under the existing auth/CSRF rules', async (mode) => {
const before = await counts();
const req = request.post('/graphql').set('Host', HOST).set('Origin', ORIGIN);
if (mode === 'bearer') req.set('Authorization', 'Bearer cors-test-session');
if (mode === 'cookie') {
const token = 'a'.repeat(64);
req.set('Cookie', `${SESSION}; csrf_token=${token}`).set('X-CSRF-Token', token);
}
const res = await req.send(MUTATION);
expect(res.body).toMatchObject({ data: { touch: { result: expect.any(String) } } });
expect(res.status).toBe(200);
expect(res.body.errors).toBeUndefined();
expect(res.body.data.touch.result).toBe(mode === 'anonymous' ? 'anonymous' : 'authenticated');
expect(res.headers['access-control-allow-origin']).toBe(ORIGIN);
expect(Number((await counts()).graphql)).toBe(Number(before.graphql) + 1);
});

it('allows authenticated non-browser requests without an Origin', async () => {
const res = await request.post('/graphql').set('Host', HOST)
.set('Authorization', 'Bearer cors-test-session').send(MUTATION);
expect(res.body).toMatchObject({ data: { touch: { result: 'authenticated' } } });
expect(res.status).toBe(200);
expect(res.body.errors).toBeUndefined();
expect(res.body.data.touch.result).toBe('authenticated');
expect(res.headers['access-control-allow-origin']).toBeUndefined();
});
});
62 changes: 62 additions & 0 deletions graphql/server/src/middleware/__tests__/cors.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import type { Request, Response } from 'express';

import type { ApiStructure } from '../../types';
import { cors } from '../cors';

const request = (origin: string | undefined, api: Partial<ApiStructure> = {}, method = 'OPTIONS') => ({
method,
headers: { origin, 'access-control-request-headers': 'content-type,x-csrf-token' },
api
} as unknown as Request);

const response = () => {
const headers = new Map<string, string>();
const res = {
getHeader: (name: string) => headers.get(name.toLowerCase()),
setHeader: (name: string, value: string) => headers.set(name.toLowerCase(), value),
vary: (name: string) => headers.set('vary', name),
status: jest.fn().mockReturnThis(),
end: jest.fn()
};
return { headers, res: res as unknown as Response, end: res.end };
};

describe('CORS origin matching and preflight termination', () => {
it.each([
['typed API origin', 'https://client.example', { corsOrigins: ['https://client.example'] }, undefined, true],
['routed domain origin', 'https://site.example', { domains: ['https://site.example'] }, undefined, true],
['exact fallback', 'https://fallback.example', {}, ' https://fallback.example ', true],
['API origin after unmatched fallback', 'https://client.example', { corsOrigins: ['https://client.example'] }, 'https://fallback.example', true],
['explicit wildcard', 'https://unlisted.example', {}, ' * ', true],
['localhost', 'http://localhost:3000', {}, undefined, true],
['localhost subdomain', 'http://app.localhost:3000', {}, undefined, true],
['localhost suffix lookalike', 'https://localhost.attacker.example', {}, undefined, false],
['origin prefix lookalike', 'https://client.example.attacker.example', { corsOrigins: ['https://client.example'] }, undefined, false],
['different scheme', 'http://client.example', { corsOrigins: ['https://client.example'] }, undefined, false],
['different port', 'https://client.example:444', { corsOrigins: ['https://client.example'] }, undefined, false],
['empty allowlist', 'https://client.example', { corsOrigins: [] }, undefined, false],
['missing metadata', 'https://client.example', {}, undefined, false],
['malformed origin', 'not-a-url', {}, undefined, false],
['opaque origin', 'null', {}, undefined, false],
['absent origin', undefined, {}, undefined, false]
] as const)('%s', (_label, origin, api, fallback, allowed) => {
const { res, headers, end } = response();
const next = jest.fn();
cors(fallback)(request(origin, api as Partial<ApiStructure>), res, next);
expect(headers.get('access-control-allow-origin')).toBe(allowed ? origin : undefined);
expect(headers.get('access-control-allow-credentials')).toBe(allowed ? 'true' : undefined);
expect(headers.get('vary')).toContain('Origin');
expect(end).toHaveBeenCalledTimes(1);
expect(next).not.toHaveBeenCalled();
});

it.each(['https://client.example', 'https://unlisted.example', undefined])(
'passes actual requests from %j to authentication even without a CORS grant', (origin) => {
const { res, end } = response();
const next = jest.fn();
cors()(request(origin, { corsOrigins: ['https://client.example'] }, 'POST'), res, next);
expect(next).toHaveBeenCalledTimes(1);
expect(end).not.toHaveBeenCalled();
}
);
});
Loading