Mario33333's picture
deploy: reader-access security hardening (feature/audiobook@06a5ed8)
7c2d250 verified
Raw
History Blame Contribute Delete
6.73 kB
"""CSRF defense via ``X-Requested-With`` header on mutating requests.
CONTRACT.md §3 + BACKEND_BUILD.md §6.4 design notes:
- The session cookie is ``SameSite=Lax``, which already blocks classic
cross-site form-POST CSRF (the browser strips the cookie). Lax does
*not* block top-level navigation POSTs initiated by ``<form>`` from an
attacker page in some niche corners, and it does nothing about GET
attacks that change state (we don't have any — all state changes are
POST/PATCH/PUT/DELETE).
- The defense-in-depth layer added here: every mutating request must
carry ``X-Requested-With: XMLHttpRequest``. Browsers refuse to add
custom headers to a cross-origin form POST without a preflight, and we
don't permit cross-origin requests (no CORS middleware in v1), so the
header's mere presence is proof the request came from our own
frontend's ``fetch`` wrapper.
- ``/auth/login`` is exempt: the login form posts before any session
exists, and exempting it doesn't open a CSRF hole — the attacker would
need the victim's password to forge a useful request.
- All ``GET`` requests are exempt by definition (the rule only fires on
mutating methods). This also makes SSE endpoints (always GET) exempt,
which is required because browser ``EventSource`` cannot set custom
headers (CONTRACT.md §4).
The middleware raises :class:`Forbidden` so the canonical
``ErrorResponse`` envelope (CONTRACT.md §8) is emitted by the global
handler — same shape as any other 403.
"""
from __future__ import annotations
import json
from collections.abc import Awaitable, Callable
from fastapi import Request, Response
from fastapi.encoders import jsonable_encoder
from fastapi.responses import JSONResponse
from src.api.errors import Forbidden
# Methods that change state on the server. GET/HEAD/OPTIONS are always
# exempt — they're either reads or preflights we don't issue.
_MUTATING_METHODS = frozenset({"POST", "PATCH", "PUT", "DELETE"})
# Paths whose mutating handlers run *before* any session exists. The login
# endpoint is the canonical exemption (you can't ask the user for an
# ``X-Requested-With`` header before they're logged in — well, you can,
# the FE fetch wrapper always sets it, but the contract doesn't require
# it for login). Match is exact; we don't accidentally exempt
# ``/auth/login/foo``.
_EXEMPT_PATHS = frozenset({"/auth/login"})
_REQUIRED_HEADER = b"x-requested-with"
_REQUIRED_VALUE = b"XMLHttpRequest"
def _build_forbidden_payload() -> bytes:
"""Pre-serialize the canonical 403 envelope.
Inlining the body avoids dragging FastAPI into the ASGI fast-path.
Shape matches what the ApiError handler would emit for the same
Forbidden subclass.
"""
err = Forbidden(
"Missing or invalid X-Requested-With header on a mutating request."
)
return json.dumps(
jsonable_encoder(err.to_envelope(), by_alias=True, exclude_none=True),
ensure_ascii=False,
).encode("utf-8")
async def _send_forbidden(send) -> None:
"""Emit the canonical 403 envelope as a complete ASGI response."""
body = _build_forbidden_payload()
await send(
{
"type": "http.response.start",
"status": 403,
"headers": [
(b"content-type", b"application/json"),
(b"content-length", str(len(body)).encode("ascii")),
],
}
)
await send({"type": "http.response.body", "body": body, "more_body": False})
class CsrfASGIMiddleware:
"""Pure-ASGI CSRF gate. Passes streams through without buffering.
Why pure ASGI and not :class:`starlette.middleware.base.BaseHTTPMiddleware`:
that adapter wraps the downstream response in an anyio memory stream
that holds chunks until enough accumulate, which breaks SSE — the
FE's :file:`SidebarJobStatus.tsx` would never receive the first
``active_jobs`` frame because the middleware buffers it indefinitely.
Verified during the FE Stage 3 audit fix-up: ``curl /system/events``
saw zero bytes for 15+ seconds with the BaseHTTPMiddleware
implementation, then a burst when the heartbeat finally tripped
a flush. After this rewrite, the first frame arrives immediately.
The rule itself is unchanged: every mutating request must carry
``X-Requested-With: XMLHttpRequest``, with ``/auth/login`` exempt.
GET / HEAD / OPTIONS pass through untouched.
"""
def __init__(self, app) -> None:
self.app = app
async def __call__(self, scope, receive, send) -> None:
if scope["type"] != "http":
await self.app(scope, receive, send)
return
method = scope.get("method", "").upper()
path = scope.get("path", "")
if method not in _MUTATING_METHODS or path in _EXEMPT_PATHS:
await self.app(scope, receive, send)
return
# Header search on raw ASGI scope: case-insensitive lookup over
# the list of (name, value) byte pairs.
ok = False
for name, value in scope.get("headers", ()): # bytes, bytes
if name.lower() == _REQUIRED_HEADER and value == _REQUIRED_VALUE:
ok = True
break
if not ok:
await _send_forbidden(send)
return
await self.app(scope, receive, send)
# ---- Legacy BaseHTTPMiddleware shim (kept as a non-default fallback) -----
async def csrf_middleware(
request: Request,
call_next: Callable[[Request], Awaitable[Response]],
) -> Response:
"""Legacy CSRF gate — kept for any caller importing it directly.
Production traffic uses :class:`CsrfASGIMiddleware` (mounted via
``app.add_middleware`` in :mod:`src.api.app`). This function is the
older :class:`BaseHTTPMiddleware`-style handler; it still works but
buffers streaming responses, so the new ASGI middleware is preferred.
Tests that imported this function continue to work.
"""
if (
request.method.upper() in _MUTATING_METHODS
and request.url.path not in _EXEMPT_PATHS
):
value = request.headers.get("x-requested-with")
if value != "XMLHttpRequest":
err = Forbidden(
"Missing or invalid X-Requested-With header on a mutating request."
)
return JSONResponse(
status_code=err.status_code,
content=jsonable_encoder(
err.to_envelope(), by_alias=True, exclude_none=True
),
)
return await call_next(request)