# 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.` / `admin.` 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.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) **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: ```env 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.` / `admin.` 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