"""FlowDeck — CSRF protection middleware.""" from __future__ import annotations import secrets from starlette.middleware.base import BaseHTTPMiddleware from starlette.requests import Request from starlette.responses import JSONResponse from app.templating import CSRF_TOKEN class CSRFMiddleware(BaseHTTPMiddleware): """Lightweight CSRF protection for state-changing requests. All POST/PUT/PATCH/DELETE requests must include X-CSRF-Token header matching the csrf_token cookie. """ SAFE_METHODS = {"GET", "HEAD", "OPTIONS"} # NOTE: ``/scim/v2`` is Bearer-token only (no cookie auth), so CSRF does not # apply — IdP SCIM clients (Okta, Entra) cannot send an X-CSRF-Token. # A19 — LISTE FINALE : plus aucun préfixe cookie-auth n'est exempté. # Tous les appels non-GET du front envoient désormais `X-CSRF-Token` # (46 sites équipés en v7.3.6 : agent_panel, settings, local_workspace, # library, gitea_workspace, workspace, workspaces, welcome). # Ne restent que du machine-to-machine / hors session : # - Bearer : /api/webhook, /api/v1, /api/v2, /scim/v2 # - callbacks : /auth/* (login, register, SSO, 2FA, WebAuthn) # - publics : /s/ (sites), /f/ (forms) # - infra/diag : /api/csrf-token (le jeton lui-même), /api/frontend-error EXCLUDED_PATHS = { "/api/webhook", "/api/v1", "/api/v2", "/scim/v2", "/auth/callback", "/auth/register", "/auth/local-login", "/auth/local-verify", "/auth/2fa", "/auth/saml", "/auth/oidc", "/auth/webauthn", "/s/", "/f/", "/api/csrf-token", "/api/frontend-error", } async def dispatch(self, request: Request, call_next): # A43 : jeton expose aux templates (base.html hx-headers) — posé AVANT # call_next, comme le nonce CSP (meme mecanisme ContextVar). CSRF_TOKEN.set(request.cookies.get("csrf_token", "")) # Webhook receiver, OAuth callback, and internal API are exempt if any(request.url.path.startswith(p) for p in self.EXCLUDED_PATHS): return await call_next(request) if request.method in self.SAFE_METHODS: response = await call_next(request) # Set CSRF cookie if not present if "csrf_token" not in request.cookies: response.set_cookie( "csrf_token", secrets.token_hex(32), httponly=False, # Must be readable by JS samesite="lax", max_age=86400, path="/", ) return response # Validate CSRF for state-changing methods csrf_cookie = request.cookies.get("csrf_token", "") csrf_header = request.headers.get("X-CSRF-Token", "") if not csrf_cookie or not csrf_header or not secrets.compare_digest(csrf_cookie, csrf_header): return JSONResponse( {"detail": "CSRF validation failed"}, status_code=403, ) return await call_next(request)