fix(api): guard cookie writes with an Origin/Referer check #423
Loading…
Reference in a new issue
No description provided.
Delete branch "fix/BUNYIP-423-api-origin-csrf-guard"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
bunyip-api authenticated every /v1 handler from the
access_tokencookie 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::OriginGuardmiddleware rejects any state-changing request (anything but GET/HEAD/OPTIONS) that carries anaccess_tokenorrefresh_tokencookie unless itsOrigin(orRefererhost 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/stripeis 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
Originto every non-GET/HEAD request, using the literalnullwhen the referrer policy suppresses the value, andnullfails 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: nullis 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