Files
scan-receipts/docs/CORS_POLICY.md
Timo 84b9987c49 Add full application: receipt scanning, auth, billing, and account deletion
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>
2026-08-19 20:59:04 +02:00

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:

  1. The app authenticates with cookies (guest sessions, the app.<domain> / admin.<domain> subdomains). Browsers refuse Access-Control-Allow-Origin: * for credentialed requests, so a wildcard would simply break the feature it claims to enable.
  2. 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.tssrc/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) plus NEXT_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 (so https://App.example.com/ and https://app.example.com are 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 Origin header = 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

  1. 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.com
    

    NEXT_PUBLIC_APP_URL must not be repeated in CORS_ORIGINS.

  2. docker-compose.yml already forwards CORS_ORIGINS into 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 pass ADMIN_EMAILS.

  3. app.<domain> / admin.<domain> requests are routed back into this app by the middleware, so their /api/* calls are same-origin and need no CORS_ORIGINS entry. Only add an origin when a page on one host genuinely calls the API of another host (e.g. https://admin.example.com).

  4. Leave CORS_ORIGINS empty for same-origin-only access — the secure default: any cross-origin browser request is refused.

Production note: NEXT_PUBLIC_APP_URL must be an https:// URL (the app sends HSTS), so CORS_ORIGINS entries should be https:// 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 — documented CORS_ORIGINS variable
  • scripts/verify_cors.ts — test harness