From aeb59b74e440f20d743ebadf7c10d9ca46a8260b Mon Sep 17 00:00:00 2001 From: Anmol1696 Date: Tue, 3 Nov 2026 13:31:13 +0400 Subject: [PATCH] fix(graphql-server): resolve API CORS before preflight Resolve scoped API metadata before CORS and stop denied OPTIONS requests before authentication, uploads, or GraphQL. Preserve explicit fallback and localhost matching, and vary responses by Origin. Add origin edge cases and real-server regressions with ephemeral SQL reader fixtures covering preflight, metadata failures, auth, and CSRF. Refs constructive-io/constructive-planning#2422 and constructive-io/constructive-cloud#285. Origin policy and Cloud rollout remain separate. --- .../__fixtures__/seed/cors/schema.sql | 102 +++++++++ .../__tests__/cors.integration.test.ts | 199 ++++++++++++++++++ .../src/middleware/__tests__/cors.test.ts | 62 ++++++ graphql/server/src/middleware/cors.ts | 19 +- graphql/server/src/server.ts | 12 +- 5 files changed, 387 insertions(+), 7 deletions(-) create mode 100644 graphql/server-test/__fixtures__/seed/cors/schema.sql create mode 100644 graphql/server-test/__tests__/cors.integration.test.ts create mode 100644 graphql/server/src/middleware/__tests__/cors.test.ts 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,