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