Files
chicken_babies_site/app/services/csrf.py
Phillip Tarrant 9a8506970c feat: phase 4 admin CMS — dashboard, editor, media, CSRF
Head Hen CMS end-to-end: dashboard lists all posts (drafts + published),
Markdown editor with live preview + drag-drop image upload, Pillow media
pipeline re-encoding every upload to JPEG, post CRUD + publish toggle +
hard delete, About page edit, and double-submit CSRF cookie enforced on
every admin mutating endpoint (Phase 3's TODO markers resolved).

Slug auto-generated on create and server-locked once a post has been
published. Unpublish preserves `published_at` so re-publish keeps
original date ordering. Every admin write invalidates the read-side
Post/Page TTL caches and records an `auth_events` audit row.

CSRF middleware is narrow by design — issues/refreshes the `cb_csrf`
cookie only on `GET /admin*`, and mutating endpoints opt in via
`require_csrf_form` or `require_csrf_header` Depends. Public routes,
healthz, and pre-auth login stay untouched.

64 new tests cover slugs, CSRF, media, admin posts/pages services, and
end-to-end CMS routes. Tests never mock the DB — real temp SQLite files
per the CLAUDE.md mandate.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 20:42:01 -05:00

168 lines
6.5 KiB
Python

"""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=<prod>`` 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