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:
@@ -1,5 +1,21 @@
|
||||
# Changelog - FlowDeck
|
||||
|
||||
## v7.3.5 (2026-09-30) — Audit sécurité : A19 (partiel) — CSRF réduit aux vrais cas
|
||||
|
||||
### Fixed
|
||||
|
||||
- **A19 (partiel)** — 12 préfixes sortis de `EXCLUDED_PATHS` après scan des
|
||||
appels non-GET du front (tous 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` — les 2 fetch de
|
||||
`welcome.html` équipés du header
|
||||
- La liste ne garde que Bearer/webhooks/callbacks/pages publiques + les 5
|
||||
préfixes dont le front n'est pas encore équipé (`/api/workspace`,
|
||||
`/api/local-workspace`, `/api/settings`, `/api/gitea`, `/api/agent`)
|
||||
- Helper `anon_csrf()` en test (anonyme + CSRF valide → on mesure le 401 de la
|
||||
route, pas le 403 du middleware) → suite **1026/1026**
|
||||
|
||||
## v7.3.4 (2026-09-30) — Audit sécurité : A16 (ACL sur lectures)
|
||||
|
||||
### Fixed
|
||||
|
||||
+2
-2
@@ -1140,7 +1140,7 @@ Quality DB views, Agent IA Palette → Realtime + E
|
||||
- [x] **A16 — Lectures de pages/export sans aucune ACL** : `export.py:53` (`_load_page_or_404` = simple `SELECT ... WHERE id=?`), `dashboard.py:1141-1186` (`download_page_file`, `page_file_content`), et la lecture legacy `board.py:1420-1424` → contenu de **toute** page énumérable par id, sans session. *Fix : passer par `PermissionManager.can_view_page` + 401 anonymous. Effort : **M**.*
|
||||
- [x] **A17 — Router legacy `/api` qui mute sans auth** : `move_card` (`api.py:98`), `set_col_mapping` (177), `delete_col_mapping` (207), `create_issue`/`update_issue` (281/322), `delete_checklist[_item]` (522/531), `PUT /users/me` (558) → seul garde = `_check_rate_limit`. *Fix : un `dependencies=[Depends(...)]` au niveau du router (session **ou** Bearer). Effort : **S**.*
|
||||
- [x] **A18 — Collection publiée quelconque + stocké XSS** : `GET /workspace/public/{collection_id}` (`workspace.py:699-719`) « no auth required », **ignore les flags `restricted/private`**, et interpole `coll['name']`/`p['title']` dans un `HTMLResponse(f"""…""")` sans `html.escape`. *Fix : respecter les flags de partage + `html.escape`. Effort : **S**.*
|
||||
- [ ] **A19 — Liste CSRF trop large (34 préfixes, match `startswith`)** : `csrf.py:21,25` couvre `/api/v2`, `/api/admin`, `/db/`, `/workspace`, `/api/user`, `/api/settings`, `/board/api/pages`, `/api/local-workspace`, `/api/comments`, `/api/agent`, `/api/automations`, `/auth/2fa` — tous **cookie-auth**. Seul `/scim/v2` est justifié par le commentaire de la ligne 19-20. Bonus : `/api/workspace` exempt aussi `/api/workspaces/*`. Filet restant = `SameSite=Lax` par défaut (jamais déclaré explicitement dans `main.py:150`). *Fix : garder un petit ensemble SAFE (webhooks, `/api/v1`, `/api/v2` Bearer, `/scim/v2`, callbacks OAuth/SSO) + ancrer les préfixes ; ajouter le header sur les 49 `fetch()` concernés (helper `csrfFetch` existe déjà : `base.html:892`). Effort : **M**.*
|
||||
- [ ] **A19 — Liste CSRF trop large (34 préfixes, match `startswith`) — PARTIEL 2026-09-30 : 12 préfixes retirés après scan du front** (`/db/`, `/workspace`, `/workspace/automations`, `/board/api/favorites`, `/api/pages`, `/api/recents`, `/api/notifications`, `/api/comments`, `/api/automations`, `/api/github`, `/api/admin`, `/api/onboarding` — les 2 call sites `welcome.html` équipés de `X-CSRF-Token`) ; reste : `/api/workspace`(+`/api/workspaces`), `/api/local-workspace`, `/api/settings`, `/api/gitea`, `/api/agent` = 51 fetch sans header à équiper (helper `adminFetch`/`csrfFetch` déjà en place) : `csrf.py:21,25` couvre `/api/v2`, `/api/admin`, `/db/`, `/workspace`, `/api/user`, `/api/settings`, `/board/api/pages`, `/api/local-workspace`, `/api/comments`, `/api/agent`, `/api/automations`, `/auth/2fa` — tous **cookie-auth**. Seul `/scim/v2` est justifié par le commentaire de la ligne 19-20. Bonus : `/api/workspace` exempt aussi `/api/workspaces/*`. Filet restant = `SameSite=Lax` par défaut (jamais déclaré explicitement dans `main.py:150`). *Fix : garder un petit ensemble SAFE (webhooks, `/api/v1`, `/api/v2` Bearer, `/scim/v2`, callbacks OAuth/SSO) + ancrer les préfixes ; ajouter le header sur les 49 `fetch()` concernés (helper `csrfFetch` existe déjà : `base.html:892`). Effort : **M**.*
|
||||
- [ ] **A20 — CSP sans filet : `script-src 'unsafe-inline' 'unsafe-eval'`** (`security.py:67`) → aucun nonce/hash ; combiné à A10, chaque sink XSS ci-dessus tourne sans violation CSP. *Fix : externaliser le JS inline (A27), passer à `'nonce-…'`, retirer `'unsafe-eval'` (Alpine/HTMX n'en ont pas besoin par défaut), resserrer `img-src`/`connect-src`. Effort : **L**.*
|
||||
- [ ] **A21 — `sqlite3` synchrone sur l'event loop** : `get_conn()` (`db.py:833-843`) est synchrone et **510 des 689 `async def` de routes** l'appellent (805 occurrences au total ; 0 `run_in_threadpool`, 1 seul `asyncio.to_thread` dans tout le dépôt : `semantic_search.py:262`) ; connexion neuve par requête (`connect` + 2 PRAGMA), **aucun `busy_timeout`**. Chaque requête bloque la boucle. *Fix : wrapper async (`anyio.to_thread.run_sync`) partagé, migrer d'abord `api_v2`/`dashboard`/`collections`/`board` + `PRAGMA busy_timeout=5000`. Effort : **M**.*
|
||||
- [x] **A22 — Validateur d'upload = code mort** : `validate_upload()` + `ALLOWED_EXTENSIONS` + `MAX_UPLOAD_SIZE` (`security.py:33-40`) n'ont **aucun appelant** ; `upload_local_workspace_file` (`dashboard.py:1507-1574`) fait `file_path.write_bytes(content)` après seulement `Path(filename).name` — ni taille, ni extension, ni auth, et `/api/local-workspace` est exclu du CSRF. Chemin aussi codé en dur `Path(f"/data/uploads/...")` au lieu de `FLOWDECK_DATA_DIR`. *Fix : appeler `validate_upload()` avant `read()` + session. Effort : **S**.*
|
||||
@@ -1185,4 +1185,4 @@ Quality DB views, Agent IA Palette → Realtime + E
|
||||
→ Puis **A3–A8** (le bloc « fallback admin ») d'un seul tenant, puis **A10** (autoescape) qui débloque A18/A20.
|
||||
|
||||
*Audit produit le 2026-09-30 · 43 items · aucun code modifié ( ROADMAP seul ).*
|
||||
→ **A1–A9 corrigés le 2026-09-30** : deps réinstallées (`pyotp`/`webauthn`/`cbor2`), rebinding de `settings` supprimé dans `test_v54.py` → **suite 1016/1016 verts**, cycle committé (`1706ad1`) + tag `v7.3.0` poussé, `.db`/fichiers de test désindexés, `APP_SECRET_KEY` roté dans `.env` (sessions révoquées) · **A3–A8 : 401 sans session sur les routes de compte (mdp actuel exigé), tokens `/api/v1` + `/api/user` sans session → 401, CRUD membres d'espace sous session+role admin, `_require_view`/`_require_edit` sans session → 404/401, création/lecture de page sous session, `/board/api/pages` + `/api/user` sortis du CSRF exempt, seed admin sans mdp en dur (aléatoire ou `FLOWDECK_ADMIN_PASSWORD`). Tests : client connecte par defaut (`_TestSessionAuth`), helper `anon()` sur les 40 tests d'anonymat → suite 1016/1016 + ruff OK, commit `d125eb3` · **A10 : `app/templating.py` (ENV partagé + autoescape `select_autoescape(["html"])`) remplace les 29 instantiations, `|safe` retriés (corps d'issue/commentaires echappes, `sidebar_config` en `|tojson`) → suite 1016/1016, version 7.3.1 · **A11 (traversal avatar) + A18 (vue publique : 404 restricted/private + html.escape)** : `tests/test_audit_p0_fixes.py`, suite 1019/1019, version 7.3.2 · **A12/A13/A15/A17/A22/A23/A24** : garde SSRF par hop, routers automations + /api sous session/Bearer, webhooks admin+URL publique, uploads validés, N+1 en GROUP BY/executemany, 2 doublons de routes supprimés → suite 1025/1025, version 7.3.3 · **A16** : exports + pièces jointes sous session + `can_view_page` → suite 1026/1026, version 7.3.4.**
|
||||
→ **A1–A9 corrigés le 2026-09-30** : deps réinstallées (`pyotp`/`webauthn`/`cbor2`), rebinding de `settings` supprimé dans `test_v54.py` → **suite 1016/1016 verts**, cycle committé (`1706ad1`) + tag `v7.3.0` poussé, `.db`/fichiers de test désindexés, `APP_SECRET_KEY` roté dans `.env` (sessions révoquées) · **A3–A8 : 401 sans session sur les routes de compte (mdp actuel exigé), tokens `/api/v1` + `/api/user` sans session → 401, CRUD membres d'espace sous session+role admin, `_require_view`/`_require_edit` sans session → 404/401, création/lecture de page sous session, `/board/api/pages` + `/api/user` sortis du CSRF exempt, seed admin sans mdp en dur (aléatoire ou `FLOWDECK_ADMIN_PASSWORD`). Tests : client connecte par defaut (`_TestSessionAuth`), helper `anon()` sur les 40 tests d'anonymat → suite 1016/1016 + ruff OK, commit `d125eb3` · **A10 : `app/templating.py` (ENV partagé + autoescape `select_autoescape(["html"])`) remplace les 29 instantiations, `|safe` retriés (corps d'issue/commentaires echappes, `sidebar_config` en `|tojson`) → suite 1016/1016, version 7.3.1 · **A11 (traversal avatar) + A18 (vue publique : 404 restricted/private + html.escape)** : `tests/test_audit_p0_fixes.py`, suite 1019/1019, version 7.3.2 · **A12/A13/A15/A17/A22/A23/A24** : garde SSRF par hop, routers automations + /api sous session/Bearer, webhooks admin+URL publique, uploads validés, N+1 en GROUP BY/executemany, 2 doublons de routes supprimés → suite 1025/1025, version 7.3.3 · **A16** : exports + pièces jointes sous session + `can_view_page` → suite 1026/1026, version 7.3.4 · **A19 (partiel)** : 12 préfixes CSRF retirés après scan front (12 préfixes = tous ceux dont les appels portent déjà le header), reste 5 préfixes / 51 fetchs → suite 1026/1026, version 7.3.5.**
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
# WORKLOAD — FlowDeck Notion Clone
|
||||
|
||||
> **Début**: 2026-07-08 | **Version**: v7.3.4 (audit sécurité A1–A24 + A16) | **Statut**: EN COURS 🔄
|
||||
> **Début**: 2026-07-08 | **Version**: v7.3.5 (audit sécurité — A19 partiel) | **Statut**: EN COURS 🔄
|
||||
> **Cible**: parité Notion + intégration forge · **Follow-ups v7.3 livrés**: sidebar teamspaces, notif `page.updated`, charts `number` + dashboards multi-DB, unfurl forge, UI Settings → Audit — voir `ROADMAP.md § v7.3.0`
|
||||
|
||||
## Avancement Global
|
||||
|
||||
+1
-1
@@ -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
@@ -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
|
||||
|
||||
@@ -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éé 🎉');
|
||||
|
||||
@@ -72,6 +72,18 @@ def anon(client):
|
||||
return client
|
||||
|
||||
|
||||
def anon_csrf(client):
|
||||
"""Anonyme MAIS CSRF valide — comme un navigateur qui a déjà chargé une page.
|
||||
|
||||
Sert aux tests d'"isolation auth" : on veut le 401 de la route, pas le 403
|
||||
du middleware CSRF qui passerait avant.
|
||||
"""
|
||||
anon(client)
|
||||
client.cookies.set("csrf_token", "csrf-anon")
|
||||
client.headers["X-CSRF-Token"] = "csrf-anon"
|
||||
return client
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client():
|
||||
"""FastAPI TestClient with a fresh temporary SQLite database."""
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
"""Non-régression de l'audit sécurité 2026-09-30 — A11 (traversal) et A18 (XSS public)."""
|
||||
from conftest import anon
|
||||
from conftest import anon, anon_csrf
|
||||
|
||||
|
||||
def test_avatar_path_traversal_denied(client):
|
||||
@@ -60,6 +60,7 @@ def test_og_metadata_rejects_private_host(client):
|
||||
def test_automations_require_session(client):
|
||||
"""A13 : CRUD, run et press-button refusent un anonymous."""
|
||||
anon(client)
|
||||
anon_csrf(client)
|
||||
assert client.post("/workspace/automations", json={"name": "x"}).status_code == 401
|
||||
assert client.post("/workspace/automations/1/run", json={}).status_code == 401
|
||||
assert client.post("/api/automations/press-button", json={}).status_code == 401
|
||||
@@ -73,6 +74,7 @@ def test_outbound_webhook_requires_admin_and_public_url(client):
|
||||
assert r.status_code == 400
|
||||
|
||||
anon(client)
|
||||
anon_csrf(client)
|
||||
assert client.post("/workspace/webhooks", json={"url": "https://example.com/h"}).status_code == 401
|
||||
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ import os
|
||||
import tempfile
|
||||
|
||||
import pytest
|
||||
from conftest import anon, login_test_client
|
||||
from conftest import anon_csrf, login_test_client
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
|
||||
@@ -110,7 +110,7 @@ def test_share_invalid_permission_rejected(client):
|
||||
|
||||
|
||||
def test_share_requires_auth(client):
|
||||
anon(client)
|
||||
anon_csrf(client)
|
||||
pid = _make_page(client)
|
||||
r = client.post(f"/api/pages/{pid}/share", json={"email": "[email protected]", "permission": "view"})
|
||||
assert r.status_code == 401
|
||||
|
||||
@@ -10,7 +10,7 @@ from __future__ import annotations
|
||||
import secrets
|
||||
|
||||
import pytest
|
||||
from conftest import anon
|
||||
from conftest import anon, anon_csrf
|
||||
|
||||
from app.db import get_conn
|
||||
from app.services import automations as auto_svc
|
||||
@@ -114,7 +114,7 @@ def test_steps_crud_and_order(client):
|
||||
|
||||
|
||||
def test_steps_validation_and_auth(client):
|
||||
anon(client)
|
||||
anon_csrf(client)
|
||||
session, _ = _login(client)
|
||||
aid = _mkauto(client, session)
|
||||
r = client.post(f"/workspace/automations/{aid}/steps",
|
||||
|
||||
@@ -11,7 +11,7 @@ import json
|
||||
import secrets
|
||||
|
||||
import pytest
|
||||
from conftest import anon
|
||||
from conftest import anon_csrf
|
||||
|
||||
from app.db import get_conn
|
||||
from app.services import calendar_sync as cal
|
||||
@@ -118,7 +118,7 @@ def test_link_crud_and_encryption(client):
|
||||
|
||||
|
||||
def test_link_validation_and_auth(client):
|
||||
anon(client)
|
||||
anon_csrf(client)
|
||||
session, _ = _login(client)
|
||||
cid = _mkcollection(client)
|
||||
r = client.post("/api/v2/calendar-links",
|
||||
@@ -281,7 +281,7 @@ def test_freebusy_basic(client):
|
||||
|
||||
|
||||
def test_freebusy_validation(client):
|
||||
anon(client)
|
||||
anon_csrf(client)
|
||||
session, _ = _login(client)
|
||||
cid = _mkcollection(client)
|
||||
r = client.get(f"/db/{cid}/calendar/freebusy?from=2026-10-07&to=2026-10-01",
|
||||
|
||||
Reference in New Issue
Block a user