[Security 3/9] Production CORS allowlist with fail-safe denial

✅ merged · #245 · open-legal-products/mike ← amal66/mike · opened 3mo ago by amal66 · merged 3mo ago by willchen96 · +79-1 across 2 files · ↗ on GitHub

From the PR description

[Security 3/9] Production CORS allowlist with fail-safe denial

Part of the split of #227 into single-topic PRs. Index: tracking comment on #227.

TL;DR

Replace the single-origin CORS reflection with an explicit allowlist and fail-safe defaults: unknown origins get no Access-Control-Allow-Origin header (the browser blocks the response) instead of an error, preflight stays a clean 2xx/4xx, and approved request headers/methods are enumerated.

Risk to user data

Severity: medium-high. The browser's same-origin policy is what stops evil.example from reading our API's responses using a logged-in visitor's cookies/credentials. CORS is a controlled relaxation of that policy. A server that reflects any Origin back - a common dev shortcut - has relaxed it for every site on the internet, so a malicious page a user visits could read their documents, chats, and account data out of our API.

Flows affected

  • Every browser-originated API request (app.ts CORS middleware).
  • Preflight (OPTIONS) handling for all routes.
  • No change for the legitimate frontend origin.

Attack precedent

Reflected-origin and overly-permissive CORS (Access-Control-Allow-Origin: * with credentials, or naive origin echoing) is a staple finding. See PortSwigger's CORS labs for the credential-theft chains this enables.

Possible fixes, and what we chose

Option Verdict
Reflect Origin back The insecure default we're removing - allows every site.
origin: FRONTEND_URL (single string) The prior state. Works but rigid, and doesn't set headers/methods explicitly.
Throw an Error for disallowed origins Rejected - the cors package propagates the throw to Express's default handler, turning every disallowed cross-origin request (including preflight) into an HTTP 500. Noisy, and a 500 on preflight is a worse signal than a clean deny.
Allowlist Set, callback resolves false for unknown origins Chosen. Disallowed origin → header simply omitted → browser blocks the read, no 5xx. Server-to-server calls (no Origin) still allowed.
sequenceDiagram
    participant B as Browser on evil.example
    participant S as Mike backend
    B->>S: request with Origin: https://evil.example
    S->>S: allowedOrigins.has(origin)? → false
    S-->>B: response WITHOUT Access-Control-Allow-Origin
    Note over B: Same-origin policy kicks in →<br/>browser refuses to expose the body to JS

Fail-safe detail (in-code comment): the callback returns callback(null, false) rather than callback(new Error()), precisely so a disallowed origin is a silent deny, not a 500. Approved headers are limited to Authorization / Content-Type, and methods are enumerated.

What's in this PR

  • backend/src/app.ts - allowlist Set + fail-safe origin callback + explicit headers/methods.
  • Tests: cors.test.ts (4 integration tests: allowed origin reflected with credentials, disallowed origin omitted, disallowed origin is not a 5xx, header allowlist enforced).

Ported from #227's index.ts change onto the post-refactor app.ts (the Express app moved during the integration-test extraction on main). The 4 tests are new, written against main's integration harness.

Reading

MDN: CORS · PortSwigger: CORS misconfigurations

Our analysis

Replace reflected CORS with a fail-safe allowlist — read the full analysis →

Think the analysis missed something the PR description covers?

Commits in this PR (1)

SHA Subject Author Date
3068002e security: production CORS allowlist with fail-safe denial Amalanand Muthukumaran 2026-07-25 ↗ GitHub
Ported from the security pack's index.ts change to the post-refactor
app.ts (the Express app moved in the integration-test extraction).
Adapted-from: https://github.com/Open-Legal-Products/mike/pull/227 (4c44c15, CORS half)

Capture this PR into my fork

Download a Markdown prompt that tells Claude how to port every commit in this PR into your working tree. Run it via claude -p < capture-pull-245.md from inside the repo you want the changes in.

⬇ Download capture-pull-245.md