- Scan de tous les appels `fetch` non-GET du front par préfixe : 12 préfixes n'ont AUCUN call site sans `X-CSRF-Token` → sortis de `EXCLUDED_PATHS` (`/db/`, `/workspace`, `/workspace/automations`, `/board/api/favorites`, `/api/pages`, `/api/recents`, `/api/notifications`, `/api/comments`, `/api/automations`, `/api/github`, `/api/admin`, `/api/onboarding`) - `welcome.html` : les 2 POST `/api/onboarding/*` reçoivent le header (`adminFetch` prouve que `/api/admin` était déjà couvert) - reste A19 (front à équiper) : `/api/workspace` (+`/api/workspaces`), `/api/local-workspace`, `/api/settings`, `/api/gitea`, `/api/agent` — 49 fetch - tests : helper `anon_csrf()` (anonyme + CSRF valide) pour isoler les 401 de route du 403 middleware — 4 tests d'anonymat ajustés - suite **1026/1026** · `ruff check app tests` OK
68 lines
2.9 KiB
Python
68 lines
2.9 KiB
Python
"""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
|
|
|
|
|
|
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 : le CSRF ne s'exempte plus que pour ce qui ne dépend PAS d'un cookie de
|
|
# session (Bearer, webhooks entrants, callbacks OAuth/SSO, pages publiques,
|
|
# probe d'infra, remontée d'erreur client).
|
|
# Retirés après scan du front (tous les appels non-GET envoient déjà
|
|
# `X-CSRF-Token`) : /db/, /workspace, /workspace/automations,
|
|
# /board/api/favorites, /api/pages, /api/recents, /api/notifications,
|
|
# /api/comments, /api/automations, /api/github, /api/admin, /api/onboarding.
|
|
# Reste A19 (front à équiper d'abord) : /api/workspace (couvre aussi
|
|
# /api/workspaces), /api/local-workspace, /api/settings, /api/gitea, /api/agent.
|
|
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/frontend-error", "/api/csrf-token",
|
|
"/api/workspace", "/api/local-workspace", "/api/settings", "/api/gitea", "/api/agent",
|
|
}
|
|
|
|
async def dispatch(self, request: Request, call_next):
|
|
# 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)
|