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>
168 lines
6.5 KiB
Python
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
|