fix: A19 (partiel) — CSRF réservé aux vrais cas d'exemption (v7.3.5)

- 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
This commit is contained in:
2026-09-30 23:38:03 -04:00
parent 72fcef2ba9
commit 0861f1fdbf
12 changed files with 61 additions and 16 deletions
+1 -1
View File
@@ -153,7 +153,7 @@ async def lifespan(_app: FastAPI):
app = FastAPI(
title="FlowDeck",
version="7.3.4",
version="7.3.5",
docs_url="/docs",
redoc_url="/redoc",
lifespan=lifespan,
+16 -1
View File
@@ -18,7 +18,22 @@ class CSRFMiddleware(BaseHTTPMiddleware):
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.
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", "/board/api/favorites", "/api/workspace", "/api/local-workspace", "/api/settings", "/db/", "/workspace", "/api/frontend-error", "/api/admin", "/api/gitea", "/api/github", "/api/pages", "/api/recents", "/api/csrf-token", "/api/notifications", "/api/comments", "/api/agent", "/api/automations", "/workspace/automations", "/api/onboarding", "/s/", "/f/"}
# 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
+2 -2
View File
@@ -155,7 +155,7 @@ function onboarding() {
async createWorkspace() {
this.saving = true;
try {
const r = await fetch('/api/onboarding/workspace', {method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({name:this.wsName.trim()})});
const r = await fetch('/api/onboarding/workspace', {method:'POST', headers:{'Content-Type':'application/json', 'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||''}, body: JSON.stringify({name:this.wsName.trim()})});
const d = await r.json();
if (!r.ok) { this.toast(d.detail || 'Erreur', true); return; }
this.workspaceId = d.id;
@@ -172,7 +172,7 @@ function onboarding() {
async createProject() {
this.saving = true;
try {
const r = await fetch('/api/onboarding/project', {method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({title:this.projectTitle.trim(), workspace_id:this.workspaceId})});
const r = await fetch('/api/onboarding/project', {method:'POST', headers:{'Content-Type':'application/json', 'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||''}, body: JSON.stringify({title:this.projectTitle.trim(), workspace_id:this.workspaceId})});
const d = await r.json();
if (!r.ok) { this.toast(d.detail || 'Erreur', true); return; }
this.toast('Projet créé 🎉');