No CSRF protection beyond SameSite=Lax #62

Closed
opened 2026-07-28 05:57:12 +00:00 by claude-bot · 1 comment

Severity: HIGH

The bug

Sessions are cookie-based and ambient, and the only CSRF defence is samesite="lax"
(backend/app/auth/session.py:31). There is no CSRF token, no origin check, and no
custom-header requirement — confirmed by grepping the backend.

SameSite=Lax blocks cross-site form POSTs, which covers the obvious case. Two gaps remain:

  1. Lax is same-site, not same-origin. Any other service on the same registrable domain
    is same-site — a common shape in self-hosted setups where several apps sit behind one domain.
    A compromised or XSS-bearing sibling app can issue credentialed fetch calls to Circa.
  2. In development mode — which is the defaultallow_origins=["http://localhost:5173"]
    with allow_credentials=True gives any page served from that origin full authenticated API
    access.

Impact

The realistic outcome is quiet integrity damage: mass-approving photos with wrong dates, or
wiping notes. There is no second copy of any of it.

Fix

  • Set samesite="strict" for the session cookie. The OAuth callback lands on the backend origin
    and sets the cookie there, so no cross-site entry flow needs Lax.
  • Add an Origin/Referer check on all mutating requests, rejecting anything whose origin is
    not the configured app origin.
  • If a token scheme is preferred instead, double-submit is sufficient — the SPA already routes
    everything through one fetch wrapper where a header can be added centrally.

Done when

  • Session cookie is SameSite=Strict
  • Mutating requests with a foreign or missing Origin are rejected
  • Login and normal review flows still work end to end
  • A test asserts a cross-origin POST is refused

References

  • backend/app/auth/session.py:31
  • backend/app/main.py (middleware stack; no CSRF middleware)
## Severity: HIGH ## The bug Sessions are cookie-based and ambient, and the only CSRF defence is `samesite="lax"` (`backend/app/auth/session.py:31`). There is no CSRF token, no origin check, and no custom-header requirement — confirmed by grepping the backend. `SameSite=Lax` blocks cross-**site** form POSTs, which covers the obvious case. Two gaps remain: 1. **`Lax` is same-*site*, not same-*origin*.** Any other service on the same registrable domain is same-site — a common shape in self-hosted setups where several apps sit behind one domain. A compromised or XSS-bearing sibling app can issue credentialed `fetch` calls to Circa. 2. **In development mode — which is the default** — `allow_origins=["http://localhost:5173"]` with `allow_credentials=True` gives any page served from that origin full authenticated API access. ## Impact The realistic outcome is quiet integrity damage: mass-approving photos with wrong dates, or wiping notes. There is no second copy of any of it. ## Fix - Set `samesite="strict"` for the session cookie. The OAuth callback lands on the backend origin and sets the cookie there, so no cross-site entry flow needs `Lax`. - Add an `Origin`/`Referer` check on all mutating requests, rejecting anything whose origin is not the configured app origin. - If a token scheme is preferred instead, double-submit is sufficient — the SPA already routes everything through one fetch wrapper where a header can be added centrally. ## Done when - [ ] Session cookie is `SameSite=Strict` - [ ] Mutating requests with a foreign or missing `Origin` are rejected - [ ] Login and normal review flows still work end to end - [ ] A test asserts a cross-origin POST is refused ## References - `backend/app/auth/session.py:31` - `backend/app/main.py` (middleware stack; no CSRF middleware)
claude-bot added this to the v0.1.1 milestone 2026-07-28 05:57:12 +00:00
Author

Fixed in 791d117. CI green.

Two layers, in app/middleware.py:

Session cookie moved to SameSite=strict. Nothing in the app relies on the cookie being sent on
a cross-site top-level navigation — the OIDC callback lands on the backend origin and sets the
cookie there — so the usual reason to prefer lax does not apply.

An explicit origin check on every unsafe method. This is the part that closes the gap in the
issue: SameSite is a same-site control, so a sibling host on the same registrable domain is
treated as first-party, which is a realistic shape in self-hosted deployments running several
services behind one domain.

It fails closed — a state-changing request with no Origin and no Referer is refused,
because browsers send Origin on exactly these requests. Referer is used as a fallback since some
privacy configurations strip Origin.

/api/auth/callback and /api/auth/login are exempt: the provider redirects the browser back as a
top-level navigation with no Origin, so checking there would break login.

Tested: foreign origin refused, missing origin refused, app origin passes through to the handler,
and a sibling subdomain refused — the case SameSite alone would have allowed.

**Fixed** in 791d117. CI green. Two layers, in `app/middleware.py`: **Session cookie moved to `SameSite=strict`.** Nothing in the app relies on the cookie being sent on a cross-site top-level navigation — the OIDC callback lands on the backend origin and sets the cookie there — so the usual reason to prefer `lax` does not apply. **An explicit origin check on every unsafe method.** This is the part that closes the gap in the issue: `SameSite` is a same-*site* control, so a sibling host on the same registrable domain is treated as first-party, which is a realistic shape in self-hosted deployments running several services behind one domain. It **fails closed** — a state-changing request with no `Origin` *and* no `Referer` is refused, because browsers send `Origin` on exactly these requests. `Referer` is used as a fallback since some privacy configurations strip `Origin`. `/api/auth/callback` and `/api/auth/login` are exempt: the provider redirects the browser back as a top-level navigation with no `Origin`, so checking there would break login. Tested: foreign origin refused, missing origin refused, app origin passes through to the handler, and a sibling subdomain refused — the case `SameSite` alone would have allowed.
Sign in to join this conversation.
No description provided.