Spaces:
Running
Running
| """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) | |