Skip to content

fix(backend): restrict CORS to FRONTEND_URL in production - #1598

Open
Nife-tanny wants to merge 4 commits into
LabsCrypt:mainfrom
Nife-tanny:fix/restrict-cors-production
Open

Nife-tanny wants to merge 4 commits into
LabsCrypt:mainfrom
Nife-tanny:fix/restrict-cors-production

Conversation

@Nife-tanny

@Nife-tanny Nife-tanny commented Sep 30, 2026 •

Copy link
Copy Markdown
Contributor

Closes #1491

Summary

Production CORS now only allows the configured frontend origin(s), a fixed list of request headers and methods, and caches preflights. If no valid origin is configured in production, the server refuses to start instead of running open.

Note on the issue's premise: main was no longer using a bare cors(). #708 had already added an origin allowlist via CORS_ALLOWED_ORIGINS. What was still missing: header and method restrictions, preflight caching, FRONTEND_URL, fail-closed startup, and exact normalized origin matching. This PR adds those and keeps CORS_ALLOWED_ORIGINS working.

Changes

  • backend/src/config/cors.ts (new): buildCorsOptions(env) plus CorsError. The options are built once when the app starts (not per request), which also makes them testable with different env values.
  • backend/src/app.ts: uses buildCorsOptions(). The existing CORS error handler now matches instanceof CorsError instead of comparing message strings, and logs the rejected origin at warn. Middleware order is unchanged: CORS runs before express.json, sandbox, API versioning, auth, and routes.
  • Docs: backend/.env.example, a new CORS section in backend/README.md, and a comment in render.yaml (no values changed).

CORS policy

NODE_ENV=production Any other NODE_ENV (dev/test/unset)
Origins FRONTEND_URL + CORS_ALLOWED_ORIGINS (comma-separated). Each is normalized with new URL(x).origin and matched exactly (no prefix/suffix/regex) Same lists, or http://localhost:3000 if neither is set (unchanged from today)
Request headers Content-Type, Authorization, X-Request-ID Reflects whatever the preflight requests (unchanged)
Methods GET, POST, PUT, PATCH, DELETE, OPTIONS cors defaults (unchanged)
Access-Control-Max-Age CORS_MAX_AGE_SECONDS, default 7200 (Chromium caps at 7200, Firefox at 86400) not set (unchanged)
Credentials true (unchanged) true (unchanged)
Preflight status 204 204
Disallowed origin 403 {"error":"CORS origin not allowed"}, no Access-Control-Allow-Origin, warn log with origin/method/path same
No Origin header Passed through, no Access-Control-Allow-Origin same

Vary: Origin is set whenever the origin is checked, because the cors package does this for function-based origins (verified by test and curl).

Fail closed: with NODE_ENV=production, startup throws if no valid origin is configured, or if any entry is not an absolute http(s) URL (e.g. *, app.flowfi.xyz, ftp://…, URLs with credentials):

Error: FRONTEND_URL is required when NODE_ENV=production: set it to the frontend origin (e.g. https://app.flowfi.xyz, comma-separate multiple origins). Refusing to start with an open CORS policy.

An invalid CORS_MAX_AGE_SECONDS also throws. In non-production, invalid entries are ignored with a warning, and an unset NODE_ENV logs a startup warning.

Deviations from the issue (with justification)

  1. PATCH added to allowed methods. The frontend calls PATCH /v1/webhooks/:id (frontend/src/lib/api/webhooks.ts:82, route backend/src/routes/v1/webhook.routes.ts:142). Without it, editing a webhook from the dashboard would fail preflight in production. PUT is kept because the issue lists it, although no route currently uses it.
  2. CORS_ALLOWED_ORIGINS is still honored and merged with FRONTEND_URL. render.yaml sets CORS_ALLOWED_ORIGINS, not FRONTEND_URL. Requiring only FRONTEND_URL would crash the existing production deployment on its next deploy.
  3. Development stays exactly as it is today (an allowlist that defaults to http://localhost:3000), not allow-any-origin. The criterion says permissive CORS is "retained" in development, and what exists today is this allowlist with unrestricted headers and methods. Switching to reflect-any-origin with credentials: true would loosen security for any environment that forgets NODE_ENV=production, and would break the existing tests/cors.test.ts and the test in open PR fix: standardize X-Request-ID propagation across middlewares and error responses #1560, which both expect a 403 for unknown origins in test mode.
  4. No exposedHeaders. The backend sends X-Request-ID, but no frontend code reads it from a response. Happy to add exposedHeaders: ['X-Request-ID'] if you want it.
  5. Rejection handled by the existing CORS error handler in app.ts, not the global errorHandler. This keeps the current response shape ({"error": "CORS origin not allowed"}, which the existing test and fix: standardize X-Request-ID propagation across middlewares and error responses #1560 rely on). It also avoids the global handler's logger.error('Unhandled error') for what is expected client traffic.

Deployment notes

  • FRONTEND_URL (or the existing CORS_ALLOWED_ORIGINS) is required when NODE_ENV=production. The app will not start without it. The current render.yaml already sets CORS_ALLOWED_ORIGINS, so no change is needed there. Environments started from backend/Dockerfile (which sets NODE_ENV=production), such as PR previews, need it too, just as they already need JWT_SECRET.
  • NODE_ENV must be production in deployed environments. Any other value, or leaving it unset, applies the development policy, and an unset value logs a startup warning.
  • Requests without an Origin header are unaffected: health checks (/health), Prometheus (/metrics), curl, server-to-server calls, and webhooks.

Acceptance criteria

  • Permissive CORS retained only in local development (NODE_ENV !== 'production'). Header/method/max-age restrictions apply only in production. Tests: buildCorsOptions (development) › leaves headers, methods and max-age unrestricted, CORS policy › development › keeps the existing allowlist with unrestricted headers and methods.
  • Unauthorized origins rejected with a CORS error in production. 403 JSON, no ACAO, warn log. Tests: rejects a disallowed origin with a 403 JSON error, no Allow-Origin, and a warning log, 9 lookalike cases (app.flowfi.xyz.evil.com, http://, a different port, a subdomain, a shared suffix, the parent domain, a trailing slash, uppercase, null), and rejects a preflight from a disallowed origin. See curl #2, #3, #7.
  • Preflight OPTIONS succeeds with a cached max-age. Tests: answers an allowed preflight with 204 and cacheable method/header lists, uses CORS_MAX_AGE_SECONDS for the preflight max-age, answers preflights for authenticated routes without running auth. See curl #5, #6.

Tests

  • tests/cors.config.test.ts (new, 36 tests): URL normalization and rejection, production options, exact matching, no-Origin handling, merging FRONTEND_URL with CORS_ALLOWED_ORIGINS, fail-closed errors, max-age parsing, development defaults, and warnings.
  • tests/cors.test.ts (existing test kept, plus 28): runs the real app in production mode through supertest, re-importing it with a fresh env per case (same pattern as metrics.test.ts). Covers every scenario above, plus disallowed headers and methods not being echoed, multiple origins, and startup failure on missing, empty, or invalid FRONTEND_URL.

Verification

Manual check: I ran the real app with NODE_ENV=production FRONTEND_URL=https://app.flowfi.xyz/ (note the trailing slash) and curled it. I served it through vite-node without DB/workers, because main currently can't boot under plain Node (see pre-existing issues).

### 1. Allowed origin
HTTP/1.1 200 OK
Access-Control-Allow-Origin: https://app.flowfi.xyz
Vary: Origin
Access-Control-Allow-Credentials: true

### 2. Disallowed origin (https://evil.example)
HTTP/1.1 403 Forbidden
Content-Type: application/json; charset=utf-8
{"error":"CORS origin not allowed"}

### 3. Lookalike origin (http://app.flowfi.xyz)
HTTP/1.1 403 Forbidden

### 4. No Origin header
HTTP/1.1 200 OK
Vary: Origin
Access-Control-Allow-Credentials: true
(no Access-Control-Allow-Origin, same as before this PR)

### 5. Preflight: allowed origin, PATCH, content-type/authorization/x-request-id
HTTP/1.1 204 No Content
Access-Control-Allow-Origin: https://app.flowfi.xyz
Vary: Origin
Access-Control-Allow-Credentials: true
Access-Control-Allow-Methods: GET,POST,PUT,PATCH,DELETE,OPTIONS
Access-Control-Allow-Headers: Content-Type,Authorization,X-Request-ID
Access-Control-Max-Age: 7200

### 6. Preflight to /v1/users/me (requireAuth)  -> HTTP/1.1 204 No Content
### 6b. Actual GET /v1/users/me without token -> HTTP/1.1 401 Unauthorized

### 7. Preflight from disallowed origin
HTTP/1.1 403 Forbidden

### Server log
{"level":"warn","message":"CORS origin rejected","method":"GET","origin":"https://evil.example","path":"/","requestId":"13d7714c-…"}

Startup with NODE_ENV=production and no FRONTEND_URL/CORS_ALLOWED_ORIGINS: exit code 1 with the error shown above.

Checks (the backend has no lint script, and CI does not lint the backend):

Check Result
tsc -p tsconfig.json 76 errors before, 76 after, 0 new (all pre-existing, see below)
vitest run (full backend suite) before: 70 failed / 486 passed. After: 70 failed / 550 passed. The same 70 tests fail before and after (diffed by name). All new CORS tests pass
npm run codegen:openapi drift produces drift, but all of it pre-existing (/metrics, /v1/streams/simulate, dead-letter routes); none of it from this PR
grep for any / ts-ignore / eslint-disable / .skip in changed files none

⚠️ Pre-existing failures on main (unrelated to this PR)

These are all reproducible on upstream/main at 72ec8ef, before this change:

  • npm ci fails: package-lock.json is out of sync with package.json (e.g. vitest@2.1.9 in the lockfile vs 3.2.7, @stellar/stellar-sdk@15.1.0 vs 17.2.0). This is why Frontend CI and Backend CI fail at Install dependencies on every recent main run.
  • prisma generate fails (P1012): backend/prisma/schema.prisma defines model IndexerDeadLetterEvent twice (lines 71 and 164) with different fields, so npm run build and npm test (which run it in prebuild/pretest) fail. For local verification I generated the client from a temporary untracked copy with the first definition removed. No schema change is included here.
  • 76 tsc errors, e.g. health.routes.ts references undefined indexerFailureDegraded/redisStatus/sorobanRpcOk, admin.routes.ts imports missing previewReset/previewReplay, and pg-pool.ts has no export named getPoolMetrics.
  • The backend cannot start under Node on main: SyntaxError: The requested module './pg-pool.js' does not provide an export named 'getPoolMetrics'.
  • 70 failing backend tests in health, indexer-service, soroban.service, soroban-event-worker, stream-simulation, pg-pool, worker-correlation-id, and integration/admin-* / reset-replay-race.
  • Backend Docker Image CI (build step) and Clippy also fail on main.

Other findings (not changed here)

  • SSE / Socket.IO: there is no Socket.IO server. SSE (/v1/events/subscribe) is served by the same Express app, so it is covered by this policy and needs no separate CORS config.
  • The global rate limiter runs before CORS, so preflights count toward the limit. The 2h max-age reduces this. Changing middleware order was out of scope, and fix: standardize X-Request-ID propagation across middlewares and error responses #1560 also reorders these middlewares.
  • Swagger UI "Try it out" sends Origin: <api host> for non-GET requests, so it gets a 403 unless the API's own origin is in FRONTEND_URL/CORS_ALLOWED_ORIGINS. This was already true before this PR.
  • useStreamEvents opens EventSource on /v1/events/subscribe, which requires a Bearer token that EventSource cannot send. This is unrelated to CORS.
  • The sandbox header X-Sandbox-Mode is not in the production allowlist. The frontend doesn't send it, and render.yaml disables sandbox mode.
  • Open PR fix: standardize X-Request-ID propagation across middlewares and error responses #1560 edits the same CORS error handler (it adds requestId to the body). Merge order doesn't matter much, but whichever lands second needs a small rebase.

Nife-tanny and others added 4 commits September 30, 2026 07:28
Move the CORS options into src/config/cors.ts (buildCorsOptions), resolved
once at startup. In production:

- origins come from FRONTEND_URL (comma-separated) plus the existing
  CORS_ALLOWED_ORIGINS, normalized with the URL API and matched exactly
- allowed headers: Content-Type, Authorization, X-Request-ID
- allowed methods: GET, POST, PUT, PATCH, DELETE, OPTIONS (PATCH is used
  by the frontend for PATCH /v1/webhooks/:id)
- preflights are cached for CORS_MAX_AGE_SECONDS (default 7200)
- startup throws if no valid origin is configured (fail closed)

Rejections raise a dedicated CorsError, answered with a 403 JSON error and
logged at warn level. Requests without an Origin header are unaffected.
The non-production policy is unchanged.

Refs LabsCrypt#1491

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Unit-test buildCorsOptions/normalizeOrigin, and exercise the real app in
production mode (fresh module import per case): allowed and lookalike
origins, no-Origin requests, preflight headers and max-age, preflight on
an auth-protected route, multiple origins, CORS_ALLOWED_ORIGINS
compatibility, and startup failure on missing/invalid FRONTEND_URL.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

This branch has not been deployed

No deployments
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.

[Backend] Restrict CORS allowed headers and methods configuration in production environment

1 participant