Repository navigation
fix(backend): restrict CORS to FRONTEND_URL in production - #1598
Open
Nife-tanny wants to merge 4 commits into
Open
Nife-tanny wants to merge 4 commits into
Nife-tanny wants to merge 4 commits into
Conversation
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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:
mainwas no longer using a barecors(). #708 had already added an origin allowlist viaCORS_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 keepsCORS_ALLOWED_ORIGINSworking.Changes
backend/src/config/cors.ts(new):buildCorsOptions(env)plusCorsError. 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: usesbuildCorsOptions(). The existing CORS error handler now matchesinstanceof CorsErrorinstead of comparing message strings, and logs the rejected origin atwarn. Middleware order is unchanged: CORS runs beforeexpress.json, sandbox, API versioning, auth, and routes.backend/.env.example, a new CORS section inbackend/README.md, and a comment inrender.yaml(no values changed).CORS policy
NODE_ENV=productionNODE_ENV(dev/test/unset)FRONTEND_URL+CORS_ALLOWED_ORIGINS(comma-separated). Each is normalized withnew URL(x).originand matched exactly (no prefix/suffix/regex)http://localhost:3000if neither is set (unchanged from today)Content-Type,Authorization,X-Request-IDGET, POST, PUT, PATCH, DELETE, OPTIONScorsdefaults (unchanged)Access-Control-Max-AgeCORS_MAX_AGE_SECONDS, default 7200 (Chromium caps at 7200, Firefox at 86400)true(unchanged)true(unchanged)204204403 {"error":"CORS origin not allowed"}, noAccess-Control-Allow-Origin,warnlog with origin/method/pathOriginheaderAccess-Control-Allow-OriginVary: Originis set whenever the origin is checked, because thecorspackage 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):An invalid
CORS_MAX_AGE_SECONDSalso throws. In non-production, invalid entries are ignored with a warning, and an unsetNODE_ENVlogs a startup warning.Deviations from the issue (with justification)
PATCHadded to allowed methods. The frontend callsPATCH /v1/webhooks/:id(frontend/src/lib/api/webhooks.ts:82, routebackend/src/routes/v1/webhook.routes.ts:142). Without it, editing a webhook from the dashboard would fail preflight in production.PUTis kept because the issue lists it, although no route currently uses it.CORS_ALLOWED_ORIGINSis still honored and merged withFRONTEND_URL.render.yamlsetsCORS_ALLOWED_ORIGINS, notFRONTEND_URL. Requiring onlyFRONTEND_URLwould crash the existing production deployment on its next deploy.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 withcredentials: truewould loosen security for any environment that forgetsNODE_ENV=production, and would break the existingtests/cors.test.tsand 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.exposedHeaders. The backend sendsX-Request-ID, but no frontend code reads it from a response. Happy to addexposedHeaders: ['X-Request-ID']if you want it.app.ts, not the globalerrorHandler. 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'slogger.error('Unhandled error')for what is expected client traffic.Deployment notes
FRONTEND_URL(or the existingCORS_ALLOWED_ORIGINS) is required whenNODE_ENV=production. The app will not start without it. The currentrender.yamlalready setsCORS_ALLOWED_ORIGINS, so no change is needed there. Environments started frombackend/Dockerfile(which setsNODE_ENV=production), such as PR previews, need it too, just as they already needJWT_SECRET.NODE_ENVmust beproductionin deployed environments. Any other value, or leaving it unset, applies the development policy, and an unset value logs a startup warning.Originheader are unaffected: health checks (/health), Prometheus (/metrics), curl, server-to-server calls, and webhooks.Acceptance criteria
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.warnlog. 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), andrejects a preflight from a disallowed origin. See curl #2, #3, #7.OPTIONSsucceeds 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, mergingFRONTEND_URLwithCORS_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 asmetrics.test.ts). Covers every scenario above, plus disallowed headers and methods not being echoed, multiple origins, and startup failure on missing, empty, or invalidFRONTEND_URL.Verification
Manual check: I ran the real
appwithNODE_ENV=production FRONTEND_URL=https://app.flowfi.xyz/(note the trailing slash) and curled it. I served it throughvite-nodewithout DB/workers, becausemaincurrently can't boot under plain Node (see pre-existing issues).Startup with
NODE_ENV=productionand noFRONTEND_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):
tsc -p tsconfig.jsonvitest run(full backend suite)npm run codegen:openapidrift/metrics,/v1/streams/simulate, dead-letter routes); none of it from this PRgrepforany/ts-ignore/eslint-disable/.skipin changed filesmain(unrelated to this PR)These are all reproducible on
upstream/mainat 72ec8ef, before this change:npm cifails:package-lock.jsonis out of sync withpackage.json(e.g.vitest@2.1.9in the lockfile vs3.2.7,@stellar/stellar-sdk@15.1.0vs17.2.0). This is why Frontend CI and Backend CI fail at Install dependencies on every recentmainrun.prisma generatefails (P1012):backend/prisma/schema.prismadefinesmodel IndexerDeadLetterEventtwice (lines 71 and 164) with different fields, sonpm run buildandnpm test(which run it inprebuild/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.tscerrors, e.g.health.routes.tsreferences undefinedindexerFailureDegraded/redisStatus/sorobanRpcOk,admin.routes.tsimports missingpreviewReset/previewReplay, andpg-pool.tshas no export namedgetPoolMetrics.main:SyntaxError: The requested module './pg-pool.js' does not provide an export named 'getPoolMetrics'.health,indexer-service,soroban.service,soroban-event-worker,stream-simulation,pg-pool,worker-correlation-id, andintegration/admin-*/reset-replay-race.main.Other findings (not changed here)
/v1/events/subscribe) is served by the same Express app, so it is covered by this policy and needs no separate CORS config.max-agereduces 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.Origin: <api host>for non-GET requests, so it gets a 403 unless the API's own origin is inFRONTEND_URL/CORS_ALLOWED_ORIGINS. This was already true before this PR.useStreamEventsopensEventSourceon/v1/events/subscribe, which requires a Bearer token thatEventSourcecannot send. This is unrelated to CORS.X-Sandbox-Modeis not in the production allowlist. The frontend doesn't send it, andrender.yamldisables sandbox mode.requestIdto the body). Merge order doesn't matter much, but whichever lands second needs a small rebase.