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>
66 lines
2.4 KiB
Python
66 lines
2.4 KiB
Python
"""FastAPI dependency for CSRF verification on admin mutating endpoints.
|
|
|
|
Mount on any route that performs a state change (POST / PUT / DELETE)
|
|
and sits inside the admin router. GET admin routes do NOT need this —
|
|
they only need ``require_admin`` to gate access.
|
|
|
|
Two flavors, same underlying check:
|
|
|
|
- :func:`require_csrf_form` — reads ``csrf_token`` from the form body.
|
|
Preferred for classic HTML forms.
|
|
- :func:`require_csrf_header` — reads ``X-CSRF-Token`` from the request
|
|
headers. Used by the live-preview fetch and the drag-drop upload
|
|
endpoint where sending an extra form field is awkward.
|
|
|
|
Both raise HTTP 403 on mismatch, which surfaces as the generic FastAPI
|
|
error page. No information leaks about which side (cookie or submitted
|
|
token) failed — fail-closed is uniform.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from typing import Optional
|
|
|
|
from fastapi import Form, HTTPException, Request
|
|
|
|
from app.services.csrf import CSRF_COOKIE_NAME, CSRFService
|
|
|
|
|
|
def _get_csrf_service(request: Request) -> CSRFService:
|
|
"""Pull the app-scoped :class:`CSRFService` off ``request.app.state``.
|
|
|
|
Private helper: route handlers depend on :func:`require_csrf_form`
|
|
or :func:`require_csrf_header` directly, not on this lookup.
|
|
"""
|
|
return request.app.state.csrf_service
|
|
|
|
|
|
def require_csrf_form(
|
|
request: Request,
|
|
csrf_token: str = Form(default=""),
|
|
) -> None:
|
|
"""Verify a form-submitted CSRF token matches the signed cookie.
|
|
|
|
Raises :class:`fastapi.HTTPException` 403 on any mismatch. On
|
|
success returns ``None`` — the dependency has no payload.
|
|
"""
|
|
service: CSRFService = _get_csrf_service(request)
|
|
cookie_value: Optional[str] = request.cookies.get(CSRF_COOKIE_NAME)
|
|
if not service.verify(cookie_value=cookie_value, submitted=csrf_token):
|
|
raise HTTPException(status_code=403, detail="CSRF verification failed")
|
|
|
|
|
|
def require_csrf_header(
|
|
request: Request,
|
|
) -> None:
|
|
"""Verify an ``X-CSRF-Token`` header matches the signed cookie.
|
|
|
|
Used by the JS-driven preview + media upload endpoints. The header
|
|
name is case-insensitive — Starlette canonicalizes on read.
|
|
"""
|
|
service: CSRFService = _get_csrf_service(request)
|
|
cookie_value: Optional[str] = request.cookies.get(CSRF_COOKIE_NAME)
|
|
submitted: Optional[str] = request.headers.get("x-csrf-token")
|
|
if not service.verify(cookie_value=cookie_value, submitted=submitted):
|
|
raise HTTPException(status_code=403, detail="CSRF verification failed")
|