"""CSRF double-submit cookie service. Protects admin-write endpoints against cross-site request forgery by requiring a signed token to be submitted BOTH as a cookie and as a form field / header. An attacker can forge requests but cannot read the cookie (SameSite=Lax blocks cross-site automatic cookie sending, and even if the browser sent it, cross-site JS still cannot read cookies on this origin). Matching the submitted value to the cookie value then proves the request originated from our own pages. Design ------ - The cookie stores a signed opaque nonce. Signing prevents a malicious ad iframe (or any JS on a non-origin page) from producing a cookie value that would later match a crafted form submission. - The nonce itself is 256-bit (``secrets.token_urlsafe(32)``), generated per-browser on first admin GET and reused for the session. Rotating per request would invalidate any still-open admin tab on every nav, which the small-scale admin UX cannot tolerate. - Verification unsigns the submitted token and compares the raw nonce to the raw nonce unsigned from the cookie using :func:`hmac.compare_digest` (constant-time) to foreclose timing side channels. - The cookie is ``HttpOnly=False`` so the minimal admin JS (live preview, upload) can read it to set the ``X-CSRF-Token`` header on fetch requests. This is the conventional double-submit cookie setup — the XSS risk is already mitigated by the Markdown sanitizer and the session cookie remains HttpOnly. The service is a small collaborator: it does not know about FastAPI routes, request objects, or templates. The :mod:`app.dependencies.csrf` module wraps the verify call in a FastAPI dependency. """ from __future__ import annotations import hmac import secrets from typing import Optional import structlog from itsdangerous import BadSignature, URLSafeTimedSerializer _log = structlog.get_logger(__name__) # Cookie name kept here as a module-level constant so routes, # dependencies, and templates stay in sync. CSRF_COOKIE_NAME: str = "cb_csrf" # Default max age — matches the session TTL ceiling. A valid admin # session already enforces the 30-day cap; the CSRF cookie merely # piggybacks. _DEFAULT_MAX_AGE_SEC: int = 30 * 86400 class CSRFService: """Issue and verify double-submit CSRF tokens. Parameters ---------- signer: Pre-built :class:`itsdangerous.URLSafeTimedSerializer`. The caller is responsible for constructing it with ``salt="csrf"`` so a session-cookie token can never be replayed as a CSRF token and vice-versa. production: When True, the issued cookie carries the ``Secure`` flag. Dev (plain-HTTP 127.0.0.1) needs it off or the browser drops the cookie entirely. """ def __init__( self, signer: URLSafeTimedSerializer, *, production: bool = False, max_age_sec: int = _DEFAULT_MAX_AGE_SEC, ) -> None: """Store the signer and cookie-policy flags by reference.""" self._signer: URLSafeTimedSerializer = signer self._production: bool = production self._max_age_sec: int = int(max_age_sec) # ------------------------------------------------------------------ # Issue # ------------------------------------------------------------------ def issue(self, existing_cookie: Optional[str] = None) -> tuple[str, str]: """Return ``(token, cookie_value)`` — reuse or mint as appropriate. If ``existing_cookie`` is a valid signed nonce (still within TTL), we reuse the underlying nonce so the same token keeps working across GET / POST cycles in the same admin session. Otherwise we mint a fresh nonce. The cookie value and the form/header token value are the SAME signed string — this is the "double submit" contract. The verify path re-signs nothing; it just compares the unsigned raw nonces. """ raw = self._unsign_or_none(existing_cookie) if raw is None: raw = secrets.token_urlsafe(32) signed = self._signer.dumps(raw) # Token and cookie are both the signed string. Callers are free # to submit either in a form field OR a header; verify accepts # both shapes. return signed, signed # ------------------------------------------------------------------ # Verify # ------------------------------------------------------------------ def verify( self, *, cookie_value: Optional[str], submitted: Optional[str], ) -> bool: """Return True iff cookie + submitted token unseal to the same nonce. Both strings must unsign cleanly; a bad signature (tampered or wrong-key) on either side fails closed. Constant-time compare on the raw nonces prevents timing leaks of the nonce bytes. """ if not cookie_value or not submitted: return False cookie_raw = self._unsign_or_none(cookie_value) submitted_raw = self._unsign_or_none(submitted) if cookie_raw is None or submitted_raw is None: return False return hmac.compare_digest(cookie_raw, submitted_raw) # ------------------------------------------------------------------ # Cookie helpers # ------------------------------------------------------------------ def cookie_params(self) -> dict: """Return kwargs for ``response.set_cookie`` matching our CSRF policy. Differences from :meth:`SessionService.cookie_params`: - ``httponly=False`` so the admin JS can read it for fetch requests. - Same ``SameSite=Lax`` + ``Secure=`` otherwise. """ return { "key": CSRF_COOKIE_NAME, "httponly": False, "samesite": "lax", "secure": self._production, "max_age": self._max_age_sec, "path": "/", } # ------------------------------------------------------------------ # Internals # ------------------------------------------------------------------ def _unsign_or_none(self, value: Optional[str]) -> Optional[str]: """Return the raw nonce, or ``None`` on any signature failure. Centralizes the "fail closed" contract; never raises to callers. """ if not value: return None try: return self._signer.loads(value, max_age=self._max_age_sec) except BadSignature: _log.info("csrf_bad_signature") return None