Repository navigation
fix(graphql-server): decide CORS per routed API and fail strictAuth closed - #1974
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.
…-per-api-strict-auth
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.api, wherereq.apiis unset and only the fallback can match. So a 404/500 thatapisends for an unknown host or failed resolution still carries CORS headers for the platform origin, and the browser shows the real error. Other origins get the 404 without CORS headers. A non-matching origin falls through to the post-routing pass.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 is sent with HTTP 503, so proxies, load balancers and client backoff can tell a misconfigured API apart from an ordinary GraphQL error. 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, an unrelated origin, and a host that resolves to no API (fallback preflight answered; 404 carries CORS headers for the fallback only).graphql/server-test/__tests__/strict-auth.integration.test.ts:SERVER_STRICT_AUTH=truewithauthenticate_strict_function_idnulled. Anonymous, bearer and cookie requests are all refused with HTTP 503 andSTRICT_AUTH_UNAVAILABLE.The routing-error and 503 tests were added in a follow-up commit and fail without its fix (5 failed / 8 passed); all 13 pass with it.
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