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>
This commit is contained in:
167
app/services/csrf.py
Normal file
167
app/services/csrf.py
Normal file
@@ -0,0 +1,167 @@
|
||||
"""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
|
||||
Reference in New Issue
Block a user