fix(auth): login hardening cluster (pre-login op_session revoke, multi-keyed rate limit, 2FA cookie hygiene) #289
No reviewers
Labels
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set
Reference
psa-systems/bunyip!289
Loading…
Reference in a new issue
No description provided.
Delete branch "fix/BUNYIP-255-login-hardening"
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-255: three login-surface hardening items from the 2026-06-30 audit.
Pre-login op_session invalidation.
establish_op_sessionpreviously minted a new sid viacreate_op_sessionwithout dropping any pre-existingbunyip_op_sessioncookie the browser was already carrying. A sibling-subdomain XSS that planted a sid before login (or a stale cookie from an unrelated browser session) would keep that sid live alongside the new one, classic session-fixation surface. Add a newOidcProvider::revoke_op_session_by_sid(runtime query so no.sqlx/cache regen) and call it fromestablish_op_sessionat the top of every login path; best-effort revoke so a no-op or DB hiccup never blocks the login.Multi-keyed login rate limit.
POST /v1/auth/loginpreviously rate-limited on per-email only, so an attacker spraying one password across many victim emails dodged the cap entirely (each victim had a fresh per-email budget). Add per-IP and per-IP-per-email windows alongside the existing per-email one, all using the existingRateLimitConfig::LOGINknob (5 / 60 s). The aggregate guessing budget from any one IP is now bounded independently of which email it targets; the per-email cap stays in place as the per-account fallback.bunyip_2fa cookie via helpers. The challenge cookie was set + cleared at four sites via raw
format!strings (bunyip_2fa=...; Path=/; HttpOnly; SameSite=Lax) that omittedSecure(HTTPS-only on production) andMax-Age(the cookie outlived the challenge token's JWT exp), and the clears omitted matching attributes so browsers might not recognize them as deletes. Addbunyip_2fa_cookie_set/bunyip_2fa_cookie_clearhelpers + aConfig::use_secure_cookies()predicate (api_public_origin.starts_with("https://")). All four sites route through the helpers soSecure,HttpOnly,SameSite=Lax,Max-Age=600(the JWT exp window) land consistently and the clears actually clear.#BUNYIP-255
BUNYIP-255: three login-surface hardening items from the 2026-06-30 audit. Pre-login op_session invalidation. `establish_op_session` previously minted a new sid via `create_op_session` without dropping any pre-existing `bunyip_op_session` cookie the browser was already carrying. A sibling-subdomain XSS that planted a sid before login (or a stale cookie from an unrelated browser session) would keep that sid live alongside the new one, classic session-fixation surface. Add a new `OidcProvider::revoke_op_session_by_sid` (runtime query so no `.sqlx/` cache regen) and call it from `establish_op_session` at the top of every login path; best-effort revoke so a no-op or DB hiccup never blocks the login. Multi-keyed login rate limit. `POST /v1/auth/login` previously rate-limited on per-email only, so an attacker spraying one password across many victim emails dodged the cap entirely (each victim had a fresh per-email budget). Add per-IP and per-IP-per-email windows alongside the existing per-email one, all using the existing `RateLimitConfig::LOGIN` knob (5 / 60 s). The aggregate guessing budget from any one IP is now bounded independently of which email it targets; the per-email cap stays in place as the per-account fallback. bunyip_2fa cookie via helpers. The challenge cookie was set + cleared at four sites via raw `format!` strings (`bunyip_2fa=...; Path=/; HttpOnly; SameSite=Lax`) that omitted `Secure` (HTTPS-only on production) and `Max-Age` (the cookie outlived the challenge token's JWT exp), and the clears omitted matching attributes so browsers might not recognize them as deletes. Add `bunyip_2fa_cookie_set` / `bunyip_2fa_cookie_clear` helpers + a `Config::use_secure_cookies()` predicate (`api_public_origin.starts_with("https://")`). All four sites route through the helpers so `Secure`, `HttpOnly`, `SameSite=Lax`, `Max-Age=600` (the JWT exp window) land consistently and the clears actually clear. #BUNYIP-255