diff --git a/CHANGELOG.md b/CHANGELOG.md index e286268..e9f8030 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/ROADMAP.md b/ROADMAP.md index 73b392e..e1d1604 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -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.** diff --git a/VERSION b/VERSION index c968a57..d9edd15 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -7.3.4 +7.3.5 diff --git a/WORKLOAD.md b/WORKLOAD.md index 4ad2b7d..a82c04c 100644 --- a/WORKLOAD.md +++ b/WORKLOAD.md @@ -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 diff --git a/app/main.py b/app/main.py index 8753bde..2af0079 100644 --- a/app/main.py +++ b/app/main.py @@ -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, diff --git a/app/middleware/csrf.py b/app/middleware/csrf.py index 98af6e7..38ae1cd 100644 --- a/app/middleware/csrf.py +++ b/app/middleware/csrf.py @@ -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 diff --git a/app/templates/welcome.html b/app/templates/welcome.html index c56d6b4..90705cb 100644 --- a/app/templates/welcome.html +++ b/app/templates/welcome.html @@ -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éé 🎉'); diff --git a/tests/conftest.py b/tests/conftest.py index e8bdabf..c395dc1 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -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.""" diff --git a/tests/test_audit_p0_fixes.py b/tests/test_audit_p0_fixes.py index 2d0ddef..a9fefa0 100644 --- a/tests/test_audit_p0_fixes.py +++ b/tests/test_audit_p0_fixes.py @@ -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 diff --git a/tests/test_sharing.py b/tests/test_sharing.py index b192109..73c8d84 100644 --- a/tests/test_sharing.py +++ b/tests/test_sharing.py @@ -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": "x@test.dev", "permission": "view"}) assert r.status_code == 401 diff --git a/tests/test_v70_automations_workers.py b/tests/test_v70_automations_workers.py index ab1aaf1..5284c48 100644 --- a/tests/test_v70_automations_workers.py +++ b/tests/test_v70_automations_workers.py @@ -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", diff --git a/tests/test_v71_calendar_meetings.py b/tests/test_v71_calendar_meetings.py index 3bac792..ce91326 100644 --- a/tests/test_v71_calendar_meetings.py +++ b/tests/test_v71_calendar_meetings.py @@ -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",