Brings the working codebase (Next.js app, auth system, Stripe billing, Docker/deploy config, tests, docs) into version control on top of the placeholder initial commit, and adds account self-deletion (Danger Zone in Settings, password + typed-email confirmation, cascading DB cleanup, Stripe cancellation) per GDPR right-to-erasure. Excludes local build caches, node_modules, and internal agent scratch files; .gitignore hardened to keep those out going forward. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
4.9 KiB
CORS Policy
This document describes how the receipt-scanner app decides which browser origins may call its API, and how to configure additional ones.
Policy in one sentence
Only origins on an explicit allowlist may call the API cross-origin — the
app never responds with Access-Control-Allow-Origin: *.
A wildcard is not an option here for two reasons:
- The app authenticates with cookies (guest sessions, the
app.<domain>/admin.<domain>subdomains). Browsers refuseAccess-Control-Allow-Origin: *for credentialed requests, so a wildcard would simply break the feature it claims to enable. - A wildcard would let any website drive a user's browser into the API with their cookies (classic CSRF/CORS-abuse surface). Locking the list down to known origins is the point of this change.
How it works
All enforcement lives in the Edge middleware (src/middleware.ts →
src/lib/http/cors.ts) so it runs before any route handler, on every request
the matcher covers (including /api/*; static assets and demo/ are excluded
by the existing matcher). next.config.ts is unchanged.
| Request | Origin header | Result |
|---|---|---|
Preflight (OPTIONS + Access-Control-Request-Method) |
allowed | 204 with Access-Control-Allow-Origin (exact echo), Vary: Origin, Access-Control-Allow-Credentials: true, Access-Control-Allow-Methods: GET,POST,PUT,PATCH,DELETE,OPTIONS, bounded echo of Access-Control-Allow-Headers, Access-Control-Max-Age: 600 |
| Preflight | disallowed / absent | 403 JSON { "error": "cors_origin_not_allowed" } |
| Any request | disallowed | 403 JSON { "error": "cors_origin_not_allowed" } |
| Any request | none (same-origin fetch, curl, server-to-server) | allowed, request continues untouched |
| Any request | allowed | allowed, request continues untouched |
Matching rules
- The allowlist is
CORS_ORIGINS(comma-separated) plusNEXT_PUBLIC_APP_URL, which is always allowed (it is the app's own origin). - Comparison is exact on host + port; trailing slashes are stripped,
hostnames are lower-cased, and the
http:///https://scheme prefix is ignored for the comparison (sohttps://App.example.com/andhttps://app.example.comare the same entry). The full origin from the request is echoed back in the header — never a wildcard, never a prefix, never a substring match. - The literal origin
"null"(sandboxed iframes,file://pages) is always rejected. - No
Originheader = not a browser cross-origin request = allowed.
Credentials
Because the app uses cookies, every allowed CORS response advertises
Access-Control-Allow-Credentials: true. This is safe precisely because the
allow-origin value is always an exact echo of an allowlisted origin, never *.
Browser CORS will only accept the response when both conditions hold.
Non-preflight responses
The middleware enforces and answers preflights; actual (non-preflight)
responses pass through untouched. Code that deliberately serves the API to
cross-origin callers should attach CORS headers from
corsHeadersFor(request.headers.get("origin")) (see src/lib/http/cors.ts).
Same-origin callers (the default: pages and /api/* share one origin) never
need any of this.
Configuration
-
In
.env(or the container environment), list every additional origin, comma-separated, full scheme://host[:port] form:NEXT_PUBLIC_APP_URL=https://app.example.com CORS_ORIGINS=https://admin.example.com,https://partner.example.comNEXT_PUBLIC_APP_URLmust not be repeated inCORS_ORIGINS. -
docker-compose.ymlalready forwardsCORS_ORIGINSinto the app container (- CORS_ORIGINS=${CORS_ORIGINS:-}). If you deploy without Docker Compose, pass it as a runtime env var to the standalone server the same way you passADMIN_EMAILS. -
app.<domain>/admin.<domain>requests are routed back into this app by the middleware, so their/api/*calls are same-origin and need noCORS_ORIGINSentry. Only add an origin when a page on one host genuinely calls the API of another host (e.g.https://admin.example.com). -
Leave
CORS_ORIGINSempty for same-origin-only access — the secure default: any cross-origin browser request is refused.
Production note:
NEXT_PUBLIC_APP_URLmust be anhttps://URL (the app sends HSTS), soCORS_ORIGINSentries should behttps://too.
Verification
scripts/verify_cors.ts (run with npx tsx scripts/verify_cors.ts) exercises
the pure helpers against simulated requests: preflight 204/403, disallowed GET
403, no-origin pass-through, allowlist normalization, and asserts that no
header value ever contains *.
Files
src/lib/http/cors.ts— the policy (Edge-runtime compatible, pure + testable)src/middleware.ts— enforcement hook.env.example— documentedCORS_ORIGINSvariablescripts/verify_cors.ts— test harness