diff --git a/graphql/server-test/__fixtures__/seed/cors/schema.sql b/graphql/server-test/__fixtures__/seed/cors/schema.sql new file mode 100644 index 0000000000..362870ddee --- /dev/null +++ b/graphql/server-test/__fixtures__/seed/cors/schema.sql @@ -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; $$; diff --git a/graphql/server-test/__tests__/cors.integration.test.ts b/graphql/server-test/__tests__/cors.integration.test.ts new file mode 100644 index 0000000000..2fc40b9b72 --- /dev/null +++ b/graphql/server-test/__tests__/cors.integration.test.ts @@ -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) | 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(); + }); +}); diff --git a/graphql/server/src/middleware/__tests__/cors.test.ts b/graphql/server/src/middleware/__tests__/cors.test.ts new file mode 100644 index 0000000000..ab2a4b0835 --- /dev/null +++ b/graphql/server/src/middleware/__tests__/cors.test.ts @@ -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 = {}, 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(); + 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), 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(); + } + ); +}); diff --git a/graphql/server/src/middleware/cors.ts b/graphql/server/src/middleware/cors.ts index 8bb7cefebb..767de2769c 100644 --- a/graphql/server/src/middleware/cors.ts +++ b/graphql/server/src/middleware/cors.ts @@ -15,7 +15,9 @@ import type { ApiStructure } from '../types'; * - Always allows localhost to ease development. * * Usage: + * app.use(createApiMiddleware(opts)); * app.use(cors(fallbackOrigin)); + * app.use(authenticate); */ export const cors = (fallbackOrigin?: string): RequestHandler => { // Use the cors library's dynamic origin function to decide per request @@ -64,12 +66,25 @@ export const cors = (fallbackOrigin?: string): RequestHandler => { }; // Wrap in the cors plugin with our dynamic origin resolver - const handler: RequestHandler = (req, res, next) => + const handler: RequestHandler = (req, res, next) => { + // Denied responses also vary by Origin: a cache must not reuse an allowed + // response (or a denial) for another origin. + res.vary('Origin'); corsPlugin({ origin: (reqOrigin, cb) => dynamicOrigin(reqOrigin, cb as any, req), credentials: true, optionsSuccessStatus: 200, - })(req, res, next); + })(req, res, (err) => { + if (err) return next(err); + // cors ends allowed preflights itself, but calls next() for a denied + // origin. End those too, without CORS grants or downstream execution. + if (req.method === 'OPTIONS') { + res.status(200).end(); + return; + } + next(); + }); + }; return handler; }; diff --git a/graphql/server/src/server.ts b/graphql/server/src/server.ts index 8e922ad33a..1463436be7 100644 --- a/graphql/server/src/server.ts +++ b/graphql/server/src/server.ts @@ -153,6 +153,13 @@ class Server { app.use(poweredBy('constructive')); app.use(cookieParser()); + app.use(parseDomains() as RequestHandler); + app.use(clientIpMiddleware); + app.use(requestIdMiddleware()); + app.use(requestLogger); + // Resolve scoped CORS metadata first. Unknown APIs and metadata failures + // must not receive a fallback grant. Preflights stop before auth or uploads. + app.use(api); app.use(cors(fallbackOrigin)); app.use('/graphql', graphqlUpload.graphqlUploadExpress({ maxFileSize: 10 * 1024 * 1024, // 10 MB @@ -161,11 +168,6 @@ class Server { // Rewrite Content-Type after graphql-upload so grafserv accepts the request app.use('/graphql', multipartBridge); - app.use(parseDomains() as RequestHandler); - app.use(clientIpMiddleware); - app.use(requestIdMiddleware()); - app.use(requestLogger); - app.use(api); app.use(authenticate); app.use(createContextMiddleware({ pg: effectiveOpts.pg,