Skip to content

fix(graphql-server): decide CORS per routed API and fail strictAuth closed - #1974

Open
pyramation wants to merge 1 commit into
mainfrom
devin/1794024329-cors-per-api-strict-auth
Open

pyramation wants to merge 1 commit into
mainfrom
devin/1794024329-cors-per-api-strict-auth

Conversation

@pyramation

Copy link
Copy Markdown
Contributor

Summary

These are the server-side prerequisites for the deploy CORS/strictAuth gate (constructive-cloud docs/architecture/launch-auth-edge-security-decisions.md, constructive-io/constructive-cloud#321). Both bugs are reproduced by new real-HTTP tests in graphql/server-test that fail on main (6 failed / 4 passed) and pass with this change.

1. CORS is decided per routed API

Before: cors() was mounted ahead of parseDomains/api, so req.api was always undefined there. The per-API cors_settings origins were never used: preflights to an API-listed origin got a 405 with no CORS headers. On top of that, any localhost origin was always reflected with credentials.

After:

// server.ts
app.use(api);
app.use(cors(fallbackOrigin));   // moved after api resolution

// cors.ts: allow (with credentials) iff
fallback === '*' || origin === fallback || req.api?.corsOrigins?.includes(origin)
  • req.api.corsOrigins is the existing corsLoader → function_resolution.cors_origins(database_id, api_id), i.e. the typed routing_public.cors_settings rows (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.
  • Comparison is exact string equality. Lookalike, port, scheme and case variants are refused.
  • server.origin (--origin / SERVER_ORIGIN) remains as a platform-wide fallback.
  • The localhost rule is removed outright, not scoped to dev mode. The only "dev mode" notion is getNodeEnv(), which treats an unset NODE_ENV as development. A deployment that forgot to set it would therefore silently trust every localhost origin, i.e. anything listening on the user's machine, with their credentials. To allow a local app origin, list it in cors_settings or pass --origin http://localhost:3000 (that is already cnc server's documented example).
  • Dropped the api.domains branch: toApiStructure always sets it to [].

2. strictAuth fails closed

Before: with server.strictAuth on, an API whose RLS settings have no authenticate_strict (or no RLS module at all) logged "Skipping auth" and served the request unauthenticated.

After: under strictAuth, if authenticateStrict or 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_UNAVAILABLE is a new curated code in @constructive-io/errors (class internal, http 503). The response itself uses the existing respondWithGraphQLError, the same as UNAUTHENTICATED. Behavior with strictAuth off is unchanged.

Out of scope: the authenticate_strict parity 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=true with authenticate_strict_function_id nulled. Anonymous, bearer and cookie requests are all refused with STRICT_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

@devin-ai-integration

Copy link
Copy Markdown
Contributor

I'll fix CI failures and address comments from users with write access. I'll skip comments containing "(aside)".

  • Disable automatic comment, CI, and merge conflict monitoring

@tenki-reviewer

tenki-reviewer Bot commented Nov 7, 2026 •

Copy link
Copy Markdown

Review complete. 🟡 2 medium

💬 Inline comments (2)

  • 🟡 CORS headers lost on api-terminated error responses — server.ts:158
  • 🟡 Strict-auth refusal ships HTTP 200 instead of 503 — auth.ts:71

The change strengthens the GraphQL server's origin and auth posture: CORS moves to a per-request decision informed by req.api, and a new strict-auth gate rejects unauthenticated traffic when authenticateStrict is configured. Two ordering/status issues worth addressing before merge are noted as inline findings.

Files Change
graphql/server/src/middleware/cors.ts, graphql/server/src/server.ts Per-API origin validation from cors_settings with fallback-origin wildcard support; cors middleware relocated after the api middleware in the pipeline.
graphql/server/src/middleware/auth.ts Fail-closed strictAuth gate that refuses requests early and returns STRICT_AUTH_UNAVAILABLE for misconfigured APIs.
packages/errors/src/registry.ts New STRICT_AUTH_UNAVAILABLE error code registered with HTTP 503 semantics.
graphql/server-test/__tests__/cors-per-api.integration.test.ts, graphql/server-test/__tests__/strict-auth.integration.test.ts Integration tests seeding APIs with CORS/strict-auth settings and asserting origin validation and refusal behavior.

Reviewed commit: 04af3fd

@tenki-reviewer tenki-reviewer Bot left a comment •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Comment on lines 158 to +160
app.use(api);
// CORS is decided against the API the host resolved to (req.api).
app.use(cors(fallbackOrigin));

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 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.

Comment on lines +71 to +72
respondWithGraphQLError(res, errors.STRICT_AUTH_UNAVAILABLE());
return;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant