Files
chicken_babies_site/app/dependencies/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

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")