A43 (les 4 items sont clos) :
- `__CSRF_PLACEHOLDER__` supprimé : ContextVar CSRF_TOKEN posée par
CSRFMiddleware avant call_next (même mécanique que le nonce CSP),
global `{{ csrf_token() }}` dans templating, base.html rend
`{"X-CSRF-Token":{{ csrf_token()|tojson }}}` — vide si cookie absent,
`htmx:configRequest` re-lit le cookie à chaque appel → plus jamais de
jeton factice servi.
- Palette : `fetch('/api/search…')` SANS header (GET ∈ SAFE_METHODS →
CSRF inapplicable) — le JSON.parse du body-attr par frappe disparaît.
- utcnow déprécié = 0 dans app/**.py ; health loggé (A25) + booléens db/
gitea = raison ; probe réseau = voulu (test de connectivité).
A38 phase 1 (CSRF unifié) :
- `window.getCsrf()` unique dans le <head> de base.html.
- 76 lectures brutes du cookie → getCsrf() dans 13 fichiers (47 formes
`(…||[])[1]||''`, 25 déclarations `const X = match(…)` avec conversion
de leurs usages `X?X[1]:''` → `X` (noms collectés PAR FICHIER, jamais
de règle globale), 4 formes espacées).
- Définitions dupliquées supprimées : card_detail.html (multi-lignes),
database_table.js (1 ligne dans IIFE → chute sur le global).
- Les 3 variantes de base.html (IIFE getCsrf + 2 getCsrfToken) →
`return getCsrf()`.
- welcome.html garde sa lecture locale (page autonome sans base) ;
reste exactement 1 raw dans base.html (la déf head) + 2 dans welcome.
Test : test_csrf_server_rendered_no_placeholder (pas de placeholder +
token du hx-headers == cookie csrf_token).
suite **1092/1092** · ruff OK · node --check vert · docs à jour
72 lines
3.0 KiB
Python
72 lines
3.0 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
|
|
|
|
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)
|