fix(api): guard cookie writes with an Origin/Referer check #423

Merged
Claude-Run merged 1 commit from fix/BUNYIP-423-api-origin-csrf-guard into main 2026-07-31 18:57:20 +02:00
Member

bunyip-api authenticated every /v1 handler from the access_token cookie before the Authorization header, and all four auth cookies are SameSite=Lax. Lax is a cross-SITE control, so with COOKIE_DOMAIN set to a shared apex any sibling host under the same registrable domain is same-site and the browser still attaches the cookie. CORS gates response reading and preflighted writes, not a bodyless form POST, and the admin impersonate / two-factor-reset / password-reset / lifetime handlers take no body extractor, so a form auto-submitted from a compromised sibling name executed them with the victim admin's ambient cookie.

The new bunyip_api::csrf::OriginGuard middleware rejects any state-changing request (anything but GET/HEAD/OPTIONS) that carries an access_token or refresh_token cookie unless its Origin (or Referer host as a fallback) is one of the parsed CORS_ORIGIN entries. It is wrapped before .wrap(cors) so it runs inside the CORS layer: preflight is still answered by CORS and never reaches the guard. /oauth2/* and /.well-known/* are exempt because those flows are gated by PKCE + state + nonce + client authentication and are cross-origin by design (the same carve-out bunyip-web/src/csrf.rs makes), and /v1/webhooks/stripe is exempt because it is HMAC-authenticated and carries no Origin.

Deviation from the issue's proposed approach: a request with NEITHER header passes instead of being refused. Per WHATWG Fetch a browser appends Origin to every non-GET/HEAD request, using the literal null when the referrer policy suppresses the value, and null fails the allow-list like any other foreign origin, so the browser attack surface is fully covered. Failing closed on absent headers would instead break every authenticated write in the product: the bunyip-web BFF (bunyip-web/src/api/mod.rs) forwards the user's cookie server-to-server with no Origin, as do the Playwright API contexts in e2e/tests/{account,auth}. The header-absent case is not an ambient credential an attacker can ride.

Unit tests cover each acceptance criterion: a foreign Origin on the admin two-factor-reset POST gets 403 before routing, the same request from a configured CORS_ORIGIN passes, a Bearer call with no cookie and no Origin passes, the OIDC and Stripe-webhook paths are exempt, Origin: null is refused, and safe methods are never blocked.

#BUNYIP-423

bunyip-api authenticated every /v1 handler from the `access_token` cookie before the Authorization header, and all four auth cookies are SameSite=Lax. Lax is a cross-SITE control, so with COOKIE_DOMAIN set to a shared apex any sibling host under the same registrable domain is same-site and the browser still attaches the cookie. CORS gates response reading and preflighted writes, not a bodyless form POST, and the admin impersonate / two-factor-reset / password-reset / lifetime handlers take no body extractor, so a form auto-submitted from a compromised sibling name executed them with the victim admin's ambient cookie. The new `bunyip_api::csrf::OriginGuard` middleware rejects any state-changing request (anything but GET/HEAD/OPTIONS) that carries an `access_token` or `refresh_token` cookie unless its `Origin` (or `Referer` host as a fallback) is one of the parsed CORS_ORIGIN entries. It is wrapped before `.wrap(cors)` so it runs inside the CORS layer: preflight is still answered by CORS and never reaches the guard. `/oauth2/*` and `/.well-known/*` are exempt because those flows are gated by PKCE + state + nonce + client authentication and are cross-origin by design (the same carve-out bunyip-web/src/csrf.rs makes), and `/v1/webhooks/stripe` is exempt because it is HMAC-authenticated and carries no Origin. Deviation from the issue's proposed approach: a request with NEITHER header passes instead of being refused. Per WHATWG Fetch a browser appends `Origin` to every non-GET/HEAD request, using the literal `null` when the referrer policy suppresses the value, and `null` fails the allow-list like any other foreign origin, so the browser attack surface is fully covered. Failing closed on absent headers would instead break every authenticated write in the product: the bunyip-web BFF (bunyip-web/src/api/mod.rs) forwards the user's cookie server-to-server with no Origin, as do the Playwright API contexts in e2e/tests/{account,auth}. The header-absent case is not an ambient credential an attacker can ride. Unit tests cover each acceptance criterion: a foreign Origin on the admin two-factor-reset POST gets 403 before routing, the same request from a configured CORS_ORIGIN passes, a Bearer call with no cookie and no Origin passes, the OIDC and Stripe-webhook paths are exempt, `Origin: null` is refused, and safe methods are never blocked. #BUNYIP-423
fix(api): guard cookie writes with an Origin/Referer check
All checks were successful
E2E PR gate / Install + reachability (no deployment secrets) (pull_request) Successful in 14s
Check / fmt + clippy + build + tests (pull_request) Successful in 4m5s
Create release / Create release from merged PR (pull_request) Has been skipped
72787124ce
bunyip-api authenticated every /v1 handler from the `access_token` cookie before the Authorization header, and all four auth cookies are SameSite=Lax. Lax is a cross-SITE control, so with COOKIE_DOMAIN set to a shared apex any sibling host under the same registrable domain is same-site and the browser still attaches the cookie. CORS gates response reading and preflighted writes, not a bodyless form POST, and the admin impersonate / two-factor-reset / password-reset / lifetime handlers take no body extractor, so a form auto-submitted from a compromised sibling name executed them with the victim admin's ambient cookie.

The new `bunyip_api::csrf::OriginGuard` middleware rejects any state-changing request (anything but GET/HEAD/OPTIONS) that carries an `access_token` or `refresh_token` cookie unless its `Origin` (or `Referer` host as a fallback) is one of the parsed CORS_ORIGIN entries. It is wrapped before `.wrap(cors)` so it runs inside the CORS layer: preflight is still answered by CORS and never reaches the guard. `/oauth2/*` and `/.well-known/*` are exempt because those flows are gated by PKCE + state + nonce + client authentication and are cross-origin by design (the same carve-out bunyip-web/src/csrf.rs makes), and `/v1/webhooks/stripe` is exempt because it is HMAC-authenticated and carries no Origin.

Deviation from the issue's proposed approach: a request with NEITHER header passes instead of being refused. Per WHATWG Fetch a browser appends `Origin` to every non-GET/HEAD request, using the literal `null` when the referrer policy suppresses the value, and `null` fails the allow-list like any other foreign origin, so the browser attack surface is fully covered. Failing closed on absent headers would instead break every authenticated write in the product: the bunyip-web BFF (bunyip-web/src/api/mod.rs) forwards the user's cookie server-to-server with no Origin, as do the Playwright API contexts in e2e/tests/{account,auth}. The header-absent case is not an ambient credential an attacker can ride.

Unit tests cover each acceptance criterion: a foreign Origin on the admin two-factor-reset POST gets 403 before routing, the same request from a configured CORS_ORIGIN passes, a Bearer call with no cookie and no Origin passes, the OIDC and Stripe-webhook paths are exempt, `Origin: null` is refused, and safe methods are never blocked.

#BUNYIP-423
Claude-Run deleted branch fix/BUNYIP-423-api-origin-csrf-guard 2026-07-31 18:57:20 +02:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
psa-systems/bunyip!423
No description provided.