feat(middleware): double-submit CSRF protection for cookie auth (#443) [stacked on #478] - #479
Open
olegbrok wants to merge 1 commit into
Open
feat(middleware): double-submit CSRF protection for cookie auth (#443) [stacked on #478]#479olegbrok wants to merge 1 commit into
olegbrok wants to merge 1 commit into
Conversation
PR-8 of the #433 web-exposed hardening track. Defense-in-depth on top of SameSite=Strict for cookie-authenticated state-changing routes in lan/web modes. Stacked on #478 → #477 → #476 → #475 → #474 → #473 → #472. Pattern ======= Double-submit cookie + header: 1. Server issues `pinky_csrf` cookie alongside any session cookie that doesn't already have one. Value = 32-byte URL-safe random, SameSite=Strict, NOT HttpOnly (frontend reads it). 2. Frontend reads cookie, sends as `X-Pinky-CSRF` header on every state-changing request. 3. Middleware on unsafe verbs (POST/PUT/PATCH/DELETE) compares header vs cookie via secrets.compare_digest. Mismatch → 403 {"error":"csrf_check_failed"}. Why not just SameSite=Strict ============================ * Older browsers without enforcement * Multi-window cross-tab leakage * Subdomain-trust attacks if PinkyBot ever shares a parent domain * OAuth/redirect flows that intentionally use SameSite=Lax #439's `/auth/elevate` and other admin POSTs are exactly the high-value targets CSRF protects. Mode behaviour ============== * trusted — disabled (back-compat) * lan + web — enforced In LAN the cookie is set with Secure=False (localhost dev doesn't have TLS); in WEB it's Secure=True. Exemptions ========== * Bearer-token auth (Authorization: Bearer ...) — no ambient credential vulnerability * Internal MCP-signed requests (X-Pinky-Agent + X-Pinky-Signature) * `/triggers/webhook/*` (token-gated upstream) * `/twilio/*` (Twilio signature gated) * Anonymous requests (no session cookie → nothing to forge) Token rotation ============== Session-bound. Whenever a request carries a session cookie but no CSRF cookie, the response sets a fresh CSRF token. Login (which sets a new session) naturally rotates: the next response sees a session without CSRF and writes a new token. Explicit `/auth/csrf/refresh` is out of scope for this PR. Audit ===== Every CSRF rejection emits `auth.csrf.rejected` into the security audit log (#440) with ip, via_proxy, forwarded_chain, method, target, user_agent, and a redacted detail blob {"reason": "missing"|"mismatch"}. The redaction allowlist was extended in security_audit.py to register the new event type. Tests (20 new) ============== * Pure helpers: UNSAFE_METHODS, generate_csrf_token randomness, is_exempt_path. * Middleware enforcement: trusted pass-through, safe methods (GET/HEAD/OPTIONS) pass, unsafe POST without/with mismatch/with match, bearer auth exempts, internal-signed exempts, webhook + twilio paths exempt, no-session no-enforcement. * Token issuance: session without CSRF gets one (samesite=strict, httponly=false), session with CSRF doesn't reissue, no session no token, Secure flag only in web mode. * Audit: reject emits event with method/target/reason; mismatch distinguishable from missing. * E2E create_api: trusted mode no token issuance + no enforcement. 523 tests green across the full hardening suite + existing api tests. Closes part of #433. Refs #443. 🤖 Authored by Barsik
This was referenced May 13, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
PR-8 of the #433 web-exposed hardening track. Defense-in-depth on top of `SameSite=Strict` for cookie-authenticated state-changing routes in lan/web modes.
Stacked on #478 → #477 → #476 → #475 → #474 → #473 → #472.
Closes part of #433. Refs #443.
Pattern
Double-submit cookie + header:
Mode behaviour
Exemptions
Token rotation
Session-bound. Whenever a request carries a session cookie but no CSRF cookie, the response sets a fresh CSRF token. Login (which sets a new session) naturally rotates. Explicit `/auth/csrf/refresh` is out of scope for this PR.
Audit
Every CSRF rejection emits `auth.csrf.rejected` into #440's security audit log with ip / via_proxy / forwarded_chain / method / target / user_agent / `detail={"reason": "missing"|"mismatch"}`. The redaction allowlist was extended in `security_audit.py` to register the new event type.
Frontend follow-up (not in this PR)
`src/lib/csrf.ts` — fetch wrapper that reads `pinky_csrf` cookie and injects `X-Pinky-CSRF`. Lands when the SPA next gets a substantive update; in the meantime, the trusted-mode default on the Mac Mini is unaffected, and any new web deployment can wire CSRF in via env config.
Tests (20 new)
523 tests green across the full hardening suite + existing api tests.
Test plan
🤖 Opened by Barsik