Repository navigation
fix(graphql-server): decide CORS per routed API and fail strictAuth closed - #1974
pyramation wants to merge 1 commit into
Conversation
|
I'll fix CI failures and address comments from users with write access. I'll skip comments containing "(aside)".
|
|
Review complete. 🟡 2 medium 💬 Inline comments (2)
The change strengthens the GraphQL server's origin and auth posture: CORS moves to a per-request decision informed by
Reviewed commit: 04af3fd |
There was a problem hiding this comment.
This PR adds per-API CORS control (strict origin checking driven by each API's cors_settings, with a global fallback origin), a fail-closed strictAuth mode that refuses requests with STRICT_AUTH_UNAVAILABLE before authentication, the corresponding error registry entry, and two integration tests covering both behaviors.
Key findings
- 🟡 CORS headers lost on
api-terminated error responses — server.ts:158 - 🟡 Strict-auth refusal ships HTTP 200 instead of 503 — auth.ts:71
| app.use(api); | ||
| // CORS is decided against the API the host resolved to (req.api). | ||
| app.use(cors(fallbackOrigin)); |
There was a problem hiding this comment.
🟡 bug · medium
CORS headers lost on api-terminated error responses
Moving app.use(cors(fallbackOrigin)) after app.use(api) (graphql/server/src/server.ts:158-160) means any response the api middleware terminates is emitted before CORS runs. createApiMiddleware short-circuits with 404 (API not found, un-routed host, NO_VALID_SCHEMAS) and 500 responses (graphql/server/src/middleware/api.ts:465-505), and those responses previously carried Access-Control-Allow-Origin because cors was mounted first — including the fallback === '*' wildcard case, which used to get CORS headers on every response. A browser SPA on an allowed origin hitting a mistyped or unresolvable host now sees an opaque CORS failure instead of the readable 404/500 body, and OPTIONS preflights for those paths now fall through api unhandled.
📋 Prompt for AI Agents
In graphql/server/src/server.ts lines 158-160, cors was moved after app.use(api), so the 404/500 responses that createApiMiddleware sends directly (graphql/server/src/middleware/api.ts lines 465-505) no longer carry Access-Control-Allow-Origin headers, including for the global fallback origin (exact match or *). Preserve that behavior: either mount the existing cors middleware before api in addition to after it (it re-reads req.api for the per-API decision), or apply the appropriate CORS headers inside createApiMiddleware's terminate branches before res.status(...).send(...). Per-API cors_settings origins legitimately stay unavailable on unrouted requests, but the fallback-origin behavior must be preserved.
| respondWithGraphQLError(res, errors.STRICT_AUTH_UNAVAILABLE()); | ||
| return; |
There was a problem hiding this comment.
🟡 bug · medium
Strict-auth refusal ships HTTP 200 instead of 503
The strict-auth fail-closed gate calls respondWithGraphQLError(res, errors.STRICT_AUTH_UNAVAILABLE()) without an init.status (graphql/server/src/middleware/auth.ts:71), so the refusal goes out as HTTP 200 even though the registry defines the error with http: 503 (packages/errors/src/registry.ts:333). Per the helper's own contract, the status hint only travels in extensions, so proxies, load balancers, and client backoff logic cannot distinguish this infrastructure misconfiguration from a normal GraphQL operation error. Sibling admission-control refusals (standing.ts, admission-control.ts) pass { status } explicitly; this is exactly the admission-control case the helper's doc comment says needs it.
📋 Prompt for AI Agents
In graphql/server/src/middleware/auth.ts at line 71, change respondWithGraphQLError(res, errors.STRICT_AUTH_UNAVAILABLE()); to respondWithGraphQLError(res, errors.STRICT_AUTH_UNAVAILABLE(), { status: 503 });. The error is registered with http: 503 in packages/errors/src/registry.ts, but respondWithGraphQLError defaults to HTTP 200 and never applies the error's http hint, so the fail-closed refusal is invisible to proxies/load balancers/client backoff. Update the integration test in graphql/server-test/tests/strict-auth.integration.test.ts to also assert the response status is 503.
Summary
These are the server-side prerequisites for the deploy CORS/strictAuth gate (
constructive-clouddocs/architecture/launch-auth-edge-security-decisions.md, constructive-io/constructive-cloud#321). Both bugs are reproduced by new real-HTTP tests ingraphql/server-testthat fail onmain(6 failed / 4 passed) and pass with this change.1. CORS is decided per routed API
Before:
cors()was mounted ahead ofparseDomains/api, soreq.apiwas always undefined there. The per-APIcors_settingsorigins were never used: preflights to an API-listed origin got a 405 with no CORS headers. On top of that, anylocalhostorigin was always reflected with credentials.After:
req.api.corsOriginsis the existingcorsLoader→function_resolution.cors_origins(database_id, api_id), i.e. the typedrouting_public.cors_settingsrows (the API's row first, then the database default). That is the same data the constructive-db routing-plane triggers keep in sync and invalidate on change (constructive-db#4291). No new source.server.origin(--origin/SERVER_ORIGIN) remains as a platform-wide fallback.getNodeEnv(), which treats an unsetNODE_ENVasdevelopment. A deployment that forgot to set it would therefore silently trust everylocalhostorigin, i.e. anything listening on the user's machine, with their credentials. To allow a local app origin, list it incors_settingsor pass--origin http://localhost:3000(that is alreadycnc server's documented example).api.domainsbranch:toApiStructurealways sets it to[].2. strictAuth fails closed
Before: with
server.strictAuthon, an API whose RLS settings have noauthenticate_strict(or no RLS module at all) logged "Skipping auth" and served the request unauthenticated.After: under
strictAuth, ifauthenticateStrictor the private schema is missing, the request is refused before any auth or SQL runs:{ "errors": [{ "message": "Strict authentication is enabled but this API has no strict authentication function, so the request was refused.", "extensions": { "code": "STRICT_AUTH_UNAVAILABLE" } }] }STRICT_AUTH_UNAVAILABLEis a new curated code in@constructive-io/errors(classinternal, http 503). The response itself uses the existingrespondWithGraphQLError, the same asUNAUTHENTICATED. Behavior withstrictAuthoff is unchanged.Out of scope: the
authenticate_strictparity gaps in constructive-db (chain expiry, principal expiry, claims, API-key UA binding) are handled in a separate PR.Tests
graphql/server-test/__tests__/cors-per-api.integration.test.ts: real server and real routing rows. Covers preflight and POST for the API-listed origin, the platform fallback, an origin listed only for another API, lookalikes, an unlisted localhost origin, and an unrelated origin.graphql/server-test/__tests__/strict-auth.integration.test.ts:SERVER_STRICT_AUTH=truewithauthenticate_strict_function_idnulled. Anonymous, bearer and cookie requests are all refused withSTRICT_AUTH_UNAVAILABLE.Link to Devin session: https://app.devin.ai/sessions/680f7e40d4b54415bbdf1a0e81cb945e
Open in Devin Desktop: https://app.devin.ai/desktop/session/680f7e40d4b54415bbdf1a0e81cb945e?variant=devin
Requested by: @pyramation