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

105 lines
4.9 KiB
Markdown

# 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.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.<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