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>
105 lines
4.9 KiB
Markdown
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
|