fix: A20 (partiel) — CSP nonce par requête, unsafe-inline sort de script-src (v7.7.0)
FlowDeck CI / lint (push) Canceled after 0s
FlowDeck CI / test (push) Canceled after 0s
FlowDeck CI / docker (push) Canceled after 0s

- ContentSecurityPolicyMiddleware : nonce aléatoire par requête dans la
  ContextVar `CSP_NONCE` (posée avant `call_next` → visible des templates),
  `script-src 'self' 'unsafe-eval' 'nonce-…'` — plus aucun script inline
  sans nonce ne tourne (fin des XSS injectés en JS)
- 38 tags `<script>` des templates : `nonce="{{ csp_nonce() }}"` (passage
  scripté, vérifié : 0 restant) ; `LOCAL_LOGIN_HTML` (constante de module) :
  helper `_with_nonce()` au rendu ; collections.py : 3 scripts Python
  (chart/form/map) noncés
- `<meta name="htmx-config" content='{"inlineScriptNonce": …}'>` dans base.html
  : htmx ré-injecte les <script> des réponses boostées avec le bon nonce
- `script-src-attr 'unsafe-inline'` : les 74 handlers `onclick=` inline
  restent couverts (le nonce les aurait désactivés aussi)
- chart.js (cdn.jsdelivr.net) et leaflet (unpkg) ajoutés à script-src/style-src
  : vues chart/map déjà BLOQUÉES par la CSP depuis toujours
  (commentaire ponytail: upgrade = vendoriser puis retirer les hôtes)
- reste d'A20 : unsafe-eval (Alpine x-data → @alpinejs/csp), externalisation
  JS (A27), resserrer img-src/connect-src

test : test_csp_nonce_per_request (page base.html + page hors template,
nonce unique par requête)

suite **1037/1037** · `ruff check app tests` OK · docs à jour
This commit is contained in:
2026-10-01 10:41:39 -04:00
parent f706424f90
commit c718fe06de
40 changed files with 175 additions and 52 deletions
+28
View File
@@ -1,5 +1,33 @@
# Changelog - FlowDeck # Changelog - FlowDeck
## v7.7.0 (2026-10-01) — Audit : A20 (CSP — nonce, partie 1)
### Security
- **A20** — `script-src` : `'unsafe-inline'` remplacé par `'nonce-<aléatoire par
requête>'`. Le middleware CSP génère le nonce dans une `ContextVar` avant
`call_next` (visible des templates via `{{ csp_nonce() }}`) ; **38 tags
`<script>` inline** des templates, la constante de module `LOCAL_LOGIN_HTML`
(helper `_with_nonce()` au rendu) et **3 scripts Python** dans `collections.py`
le portent ; htmx reçoit le même nonce via `<meta name="htmx-config">`
(`inlineScriptNonce` — les scripts des réponses boostées restent valides)
- Les 74 handlers `onclick=` inline restent fonctionnels via
`script-src-attr 'unsafe-inline'` (détaché de `script-src` : le nonce les
aurait désactivés aussi)
- `https://cdn.jsdelivr.net` / `https://unpkg.com` ajoutés à `script-src` et
`style-src` : les vues chart/map de `collections` les utilisent et étaient
**bloquées par la CSP depuis toujours** (commentaire `ponytail:` → upgrade :
vendoriser ces libs puis retirer les hôtes)
- Reste d'A20 : `unsafe-eval` (Alpine `x-data` en string → build
`@alpinejs/csp`), externalisation du JS inline (A27), resserrer
`img-src`/`connect-src`
### Tests
- `test_csp_nonce_per_request` : page `base.html` (meta htmx-config + nonce du
header identique sur tous les scripts inline, nonce différent d'une requête à
l'autre) et page hors template (`/auth/login?provider=local`)
## v7.6.0 (2026-10-01) — Audit : A31 (dette migrations) ## v7.6.0 (2026-10-01) — Audit : A31 (dette migrations)
### Fixed ### Fixed
+2 -2
View File
@@ -1137,7 +1137,7 @@ Quality DB views, Agent IA Palette → Realtime + E
- [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] **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**.* - [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**.*
- [x] **A19 — Liste CSRF trop large (34 préfixes) — TERMINÉ 2026-09-30 : 17 préfixes retirés, aucun préfixe cookie-auth n'est plus exempté** (12 en v7.3.5 après scan + 5 en v7.3.6 après équipement des 46 call sites) (`/db/`, `/workspace`, `/workspace/automations`, `/board/api/favorites`, `/api/pages`, `/api/recents`, `/api/notifications`, `/api/comments`, `/api/automations`, `/api/github`, `/api/admin`, `/api/onboarding`) puis les 5 derniers en v7.3.6 (46 call sites équipés de `X-CSRF-Token` dans agent_panel, settings, local_workspace, library, gitea_workspace, workspace, workspaces, welcome) — il ne reste que du Bearer/callback/public/infra : `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**.* - [x] **A19 — Liste CSRF trop large (34 préfixes) — TERMINÉ 2026-09-30 : 17 préfixes retirés, aucun préfixe cookie-auth n'est plus exempté** (12 en v7.3.5 après scan + 5 en v7.3.6 après équipement des 46 call sites) (`/db/`, `/workspace`, `/workspace/automations`, `/board/api/favorites`, `/api/pages`, `/api/recents`, `/api/notifications`, `/api/comments`, `/api/automations`, `/api/github`, `/api/admin`, `/api/onboarding`) puis les 5 derniers en v7.3.6 (46 call sites équipés de `X-CSRF-Token` dans agent_panel, settings, local_workspace, library, gitea_workspace, workspace, workspaces, welcome) — il ne reste que du Bearer/callback/public/infra : `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**.* - [x] **A20 — CSP sans filet — PARTIEL : `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'`, resserrer `img-src`/`connect-src`.* — **fait 2026-10-01 (nonce)** : `script-src 'self' 'unsafe-eval' 'nonce-<per-request>'` — `unsafe-inline` retiré de script-src (fin des XSS injectés en JS) ; ContextVar `CSP_NONCE` posée par le middleware avant `call_next`, lue par `{{ csp_nonce() }}` (38 tags inline dans les templates + `_with_nonce()` pour la constante `LOCAL_LOGIN_HTML` + 3 scripts Python dans collections.py) ; htmx re-çoit le nonce via `<meta name="htmx-config">` (réponses boostées) ; les 74 handlers `onclick=` restent couverts par `script-src-attr 'unsafe-inline'` ; chart.js/leaflet (CDN, déjà utilisés par les vues chart/map et BLOQUÉS par CSP depuis toujours) ajoutés à `script-src`/`style-src` avec commentaire `ponytail:` (upgrade : vendoriser). **Reste** : `unsafe-eval` (Alpine x-data en string → build `@alpinejs/csp`), externalisation JS (A27), resserrer `img-src`/`connect-src`, vendoriser les 2 CDN. Effort : **L**.
- [ ] **A21 — `sqlite3` synchrone sur l'event loop — PARTIEL 2026-10-01 : `PRAGMA busy_timeout=5000` ajouté au point d'entrée unique `get_conn()` (db.py)** ; reste le wrapper async `anyio.to_thread` + la migration des 510 call sites : `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**.* - [ ] **A21 — `sqlite3` synchrone sur l'event loop — PARTIEL 2026-10-01 : `PRAGMA busy_timeout=5000` ajouté au point d'entrée unique `get_conn()` (db.py)** ; reste le wrapper async `anyio.to_thread` + la migration des 510 call sites : `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**.* - [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**.*
- [x] **A23 — N+1 avérés** : `dashboard.py:905` (`COUNT(*) FROM pages` par page dans une boucle de 20), `board.py:2141-2146` (INSERT + `_extract_ai_keywords` par issue), `collections.py:378` (INSERT par propriété en boucle). *Fix : une agrégation `GROUP BY` / un `executemany`. Effort : **S**.* - [x] **A23 — N+1 avérés** : `dashboard.py:905` (`COUNT(*) FROM pages` par page dans une boucle de 20), `board.py:2141-2146` (INSERT + `_extract_ai_keywords` par issue), `collections.py:378` (INSERT par propriété en boucle). *Fix : une agrégation `GROUP BY` / un `executemany`. Effort : **S**.*
@@ -1181,4 +1181,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. → 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 ).* *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 · **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 / 49 fetchs → suite 1026/1026, version 7.3.5 · **A19 terminé** : 46 call sites front équipés, plus aucun préfixe cookie-auth exempté → suite 1026/1026, version 7.3.6 · **A14** : fallback `admin` supprimé de `_current_user_id`/`_current_admin` (401 sans session), `api_base` validé scheme http(s) sans identifiants (hôtes privés acceptés = Ollama localhost, commentaire `ponytail:`) → suite 1027/1027, version 7.3.7 · **A25** : 84 `except Exception: pass` remplacés par `logger.exception(fn)` (19 fichiers, +`logger` manquants), try supprimé sur `materialize_properties` dans `create_collection_v2` et `apply_db_template_v2` (rollback au lieu d'un commit sans schéma), test de rollback · **A21 (partiel)** : `busy_timeout=5000` dans `get_conn()` → suite 1028/1028, version 7.3.8 · **A26/A33/A34/A35/A36/A43** : secret par défaut refusé au boot, rate limit (préfixes + settings + XFF + épurage), `_spawn()` pour les 10 schedulers, OpenAPI 511 chemins + README, 4 deps mortes purgées, 15 `utcnow()` → `now(UTC)` naïf → suite 1028/1028, version 7.3.9. · **A30/A37/A39/A40/A41** : `require_scope` câblé sur 69 sites + 12 fonctions mortes supprimées, CORS sans `*` (origines de `app_base_url` + regex dev/extensions), assets versionnés depuis `VERSION` (source unique), `app.css` -10,2 Ko de règles mortes, htmx = décision « rien » documentée → suite 1031/1031, version 7.4.0. · **A29/A42** : `services/publish.py` partagé (3 routers déléguent, 404 partout, board sous session), fuite `password_hash` corrigée sur `GET /api/users/me` v1 + contexte `/accounts`, `settings.data_dir` remplace les 9 copies d'env, cache Gitea évacue les expirés ; `/users/me` ×2 + collections ×3 = contrats versionnés, on garde ; reste A42 = client httpx partagé → suite 1034/1034, version 7.5.0. · **A31** : transaction par migration (`_apply_one`, rollback tout-ou-rien du DDL) + helper `columns()` remplaçant 25 copies de `PRAGMA table_info` (1 helper au lieu de 3 — les 2 autres seraient mort-nés) → suite 1036/1036, version 7.6.0. → **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 / 49 fetchs → suite 1026/1026, version 7.3.5 · **A19 terminé** : 46 call sites front équipés, plus aucun préfixe cookie-auth exempté → suite 1026/1026, version 7.3.6 · **A14** : fallback `admin` supprimé de `_current_user_id`/`_current_admin` (401 sans session), `api_base` validé scheme http(s) sans identifiants (hôtes privés acceptés = Ollama localhost, commentaire `ponytail:`) → suite 1027/1027, version 7.3.7 · **A25** : 84 `except Exception: pass` remplacés par `logger.exception(fn)` (19 fichiers, +`logger` manquants), try supprimé sur `materialize_properties` dans `create_collection_v2` et `apply_db_template_v2` (rollback au lieu d'un commit sans schéma), test de rollback · **A21 (partiel)** : `busy_timeout=5000` dans `get_conn()` → suite 1028/1028, version 7.3.8 · **A26/A33/A34/A35/A36/A43** : secret par défaut refusé au boot, rate limit (préfixes + settings + XFF + épurage), `_spawn()` pour les 10 schedulers, OpenAPI 511 chemins + README, 4 deps mortes purgées, 15 `utcnow()` → `now(UTC)` naïf → suite 1028/1028, version 7.3.9. · **A30/A37/A39/A40/A41** : `require_scope` câblé sur 69 sites + 12 fonctions mortes supprimées, CORS sans `*` (origines de `app_base_url` + regex dev/extensions), assets versionnés depuis `VERSION` (source unique), `app.css` -10,2 Ko de règles mortes, htmx = décision « rien » documentée → suite 1031/1031, version 7.4.0. · **A29/A42** : `services/publish.py` partagé (3 routers déléguent, 404 partout, board sous session), fuite `password_hash` corrigée sur `GET /api/users/me` v1 + contexte `/accounts`, `settings.data_dir` remplace les 9 copies d'env, cache Gitea évacue les expirés ; `/users/me` ×2 + collections ×3 = contrats versionnés, on garde ; reste A42 = client httpx partagé → suite 1034/1034, version 7.5.0. · **A31** : transaction par migration (`_apply_one`, rollback tout-ou-rien du DDL) + helper `columns()` remplaçant 25 copies de `PRAGMA table_info` (1 helper au lieu de 3 — les 2 autres seraient mort-nés) → suite 1036/1036, version 7.6.0. · **A20 (partiel)** : CSP nonce par requête — `unsafe-inline` retiré de `script-src`, 38 scripts templates + login constant + 3 scripts Python noncés, meta `htmx-config` pour htmx, `script-src-attr` pour les 74 `onclick=`, CDN chart/leaflet débloqués (déjà cassés avant) → suite 1037/1037, version 7.7.0.
+1 -1
View File
@@ -1 +1 @@
7.6.0 7.7.0
+1 -1
View File
@@ -1,6 +1,6 @@
# WORKLOAD — FlowDeck Notion Clone # WORKLOAD — FlowDeck Notion Clone
> **Début**: 2026-07-08 | **Version**: v7.6.0 (audit — A31) | **Statut**: EN COURS 🔄 > **Début**: 2026-07-08 | **Version**: v7.7.0 (audit — A20 partiel : nonce CSP) | **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` > **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 ## Avancement Global
+1 -1
View File
@@ -185,7 +185,7 @@ async def lifespan(_app: FastAPI):
app = FastAPI( app = FastAPI(
title="FlowDeck", title="FlowDeck",
version="7.6.0", version="7.7.0",
docs_url="/docs", docs_url="/docs",
redoc_url="/redoc", redoc_url="/redoc",
lifespan=lifespan, lifespan=lifespan,
+22 -3
View File
@@ -2,6 +2,7 @@
from __future__ import annotations from __future__ import annotations
import ipaddress import ipaddress
import secrets
import time import time
from collections import defaultdict from collections import defaultdict
@@ -9,6 +10,8 @@ from starlette.middleware.base import BaseHTTPMiddleware
from starlette.requests import Request from starlette.requests import Request
from starlette.responses import JSONResponse from starlette.responses import JSONResponse
from app.templating import CSP_NONCE
# ── Constants ──────────────────────────────────────────────── # ── Constants ────────────────────────────────────────────────
# Allowed extensions for file uploads # Allowed extensions for file uploads
@@ -63,10 +66,21 @@ class ContentSecurityPolicyMiddleware(BaseHTTPMiddleware):
""" """
CSP_HEADER = "Content-Security-Policy" CSP_HEADER = "Content-Security-Policy"
# A20 : `unsafe-inline` sort de script-src (remplacé par un nonce par
# requête — un XSS injecté dans une page ne tourne plus). Les 74 handlers
# inline `onclick=` restent couverts par `script-src-attr`, explicitement
# détaché de script-src (sinon le nonce les désactiverait aussi).
# `unsafe-eval` reste : Alpine (x-data en string) et htmx (hx-vars) en ont
# besoin → à retirer avec le build CSP d'Alpine (voir reste d'A20).
CSP_VALUE = ( CSP_VALUE = (
"default-src 'self'; " "default-src 'self'; "
"script-src 'self' 'unsafe-inline' 'unsafe-eval'; " # ponytail: chart.js et leaflet sont chargés depuis CDN par les vues
"style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; " # chart/map de collections — l'upgrade est de les vendoriser dans
# /static/js puis de retirer ces deux hôtes.
"script-src 'self' 'unsafe-eval' 'nonce-{nonce}' "
"https://cdn.jsdelivr.net https://unpkg.com; "
"script-src-attr 'unsafe-inline'; "
"style-src 'self' 'unsafe-inline' https://fonts.googleapis.com https://unpkg.com; "
"img-src 'self' data: blob: https:; " "img-src 'self' data: blob: https:; "
"font-src 'self' data: https://fonts.gstatic.com; " "font-src 'self' data: https://fonts.gstatic.com; "
"connect-src 'self' https: wss: ws:; " "connect-src 'self' https: wss: ws:; "
@@ -78,11 +92,16 @@ class ContentSecurityPolicyMiddleware(BaseHTTPMiddleware):
) )
async def dispatch(self, request: Request, call_next): async def dispatch(self, request: Request, call_next):
nonce = secrets.token_urlsafe(16)
# Posé AVANT call_next : BaseHTTPMiddleware exécute le aval dans une
# tâche créée maintenant → le contexte (donc le nonce) y est copié,
# exactement ce que les templates liront via `csp_nonce()`.
CSP_NONCE.set(nonce)
response = await call_next(request) response = await call_next(request)
# Only set CSP on HTML responses # Only set CSP on HTML responses
content_type = response.headers.get("content-type", "") content_type = response.headers.get("content-type", "")
if "text/html" in content_type: if "text/html" in content_type:
response.headers[self.CSP_HEADER] = self.CSP_VALUE response.headers[self.CSP_HEADER] = self.CSP_VALUE.format(nonce=nonce)
return response return response
+13 -3
View File
@@ -9,6 +9,7 @@ from fastapi.responses import HTMLResponse, RedirectResponse
from app.auth.session import SessionManager from app.auth.session import SessionManager
from app.config import settings from app.config import settings
from app.templating import CSP_NONCE
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
router = APIRouter(tags=["auth"], prefix="/auth") router = APIRouter(tags=["auth"], prefix="/auth")
@@ -31,6 +32,15 @@ def get_redirect_uri(request: Request) -> str:
host = fwd_host.split(",")[0].strip() or request.headers.get("host", "localhost:8080") host = fwd_host.split(",")[0].strip() or request.headers.get("host", "localhost:8080")
return f"{scheme}://{host}/auth/callback" return f"{scheme}://{host}/auth/callback"
def _with_nonce(html: str) -> str:
"""A20 : injecte le nonce CSP au moment du rendu.
`LOCAL_LOGIN_HTML` est une constante de module — le nonce, lui, est par
requête, donc il ne peut être figé qu'ici.
"""
return html.replace("<script>", f'<script nonce="{CSP_NONCE.get()}">', 1)
LOCAL_LOGIN_HTML = """<!DOCTYPE html> LOCAL_LOGIN_HTML = """<!DOCTYPE html>
<html lang="en"> <html lang="en">
<head> <head>
@@ -148,7 +158,7 @@ async function handleLogin(e){e.preventDefault();const email=document.getElement
@router.get("/register") @router.get("/register")
async def register_page(request: Request): async def register_page(request: Request):
"""Show the registration page (local login page with register tab active).""" """Show the registration page (local login page with register tab active)."""
return HTMLResponse(LOCAL_LOGIN_HTML.replace( return HTMLResponse(_with_nonce(LOCAL_LOGIN_HTML.replace(
'class="tab active" onclick="switchTab(\'login\')"', 'class="tab active" onclick="switchTab(\'login\')"',
'class="tab" onclick="switchTab(\'login\')"' 'class="tab" onclick="switchTab(\'login\')"'
).replace( ).replace(
@@ -163,7 +173,7 @@ async def register_page(request: Request):
).replace( ).replace(
'id="submit-btn">Login<', 'id="submit-btn">Login<',
'id="submit-btn">Register<' 'id="submit-btn">Register<'
), status_code=200) )), status_code=200)
@router.get("/login") @router.get("/login")
@@ -172,7 +182,7 @@ async def login(request: Request, provider: str = Query("gitea")):
# Local login page (POST handled by /auth/local-login) # Local login page (POST handled by /auth/local-login)
from fastapi.responses import HTMLResponse from fastapi.responses import HTMLResponse
if provider == "local": if provider == "local":
return HTMLResponse(LOCAL_LOGIN_HTML, status_code=200) return HTMLResponse(_with_nonce(LOCAL_LOGIN_HTML), status_code=200)
# OAuth flow — check if provider is configured # OAuth flow — check if provider is configured
from app.auth.providers import get_provider from app.auth.providers import get_provider
+4 -3
View File
@@ -27,6 +27,7 @@ from app.services.recurrence import (
validate_rule, validate_rule,
) )
from app.services.reminders import REMINDER_KEY, parse_lead from app.services.reminders import REMINDER_KEY, parse_lead
from app.templating import CSP_NONCE
def _current_user(request: Request) -> dict: def _current_user(request: Request) -> dict:
@@ -2376,7 +2377,7 @@ canvas{{max-height:400px}}
</style> </style>
<div class="chart-container"><canvas id="chartCanvas"></canvas></div> <div class="chart-container"><canvas id="chartCanvas"></canvas></div>
<script src="https://cdn.jsdelivr.net/npm/chart.js@4"></script> <script src="https://cdn.jsdelivr.net/npm/chart.js@4"></script>
<script> <script nonce="{CSP_NONCE.get()}">
new Chart(document.getElementById('chartCanvas'), {{ new Chart(document.getElementById('chartCanvas'), {{
type: '{chart_type}', type: '{chart_type}',
data: {{ data: {{
@@ -2439,7 +2440,7 @@ def _render_form(view_type: str, collection: dict, pages: list[dict], config: di
</form> </form>
<div id="formResult" style="margin-top:12px;color:#4CAF50;display:none">✓ Created successfully!</div> <div id="formResult" style="margin-top:12px;color:#4CAF50;display:none">✓ Created successfully!</div>
</div> </div>
<script> <script nonce="{CSP_NONCE.get()}">
async function submitForm(e) {{ async function submitForm(e) {{
e.preventDefault(); e.preventDefault();
const form = document.getElementById('collectionForm'); const form = document.getElementById('collectionForm');
@@ -2490,7 +2491,7 @@ def _render_map(view_type: str, collection: dict, pages: list[dict], config: dic
<link rel="stylesheet" href="https://unpkg.com/[email protected]/dist/leaflet.css" /> <link rel="stylesheet" href="https://unpkg.com/[email protected]/dist/leaflet.css" />
<div id="map"></div> <div id="map"></div>
<script src="https://unpkg.com/[email protected]/dist/leaflet.js"></script> <script src="https://unpkg.com/[email protected]/dist/leaflet.js"></script>
<script> <script nonce="{CSP_NONCE.get()}">
const map = L.map('map').setView([{center_lat}, {center_lng}], 6); const map = L.map('map').setView([{center_lat}, {center_lng}], 6);
L.tileLayer('https://{{s}}.tile.openstreetmap.org/{{z}}/{{x}}/{{y}}.png', {{attribution:'© OSM'}}).addTo(map); L.tileLayer('https://{{s}}.tile.openstreetmap.org/{{z}}/{{x}}/{{y}}.png', {{attribution:'© OSM'}}).addTo(map);
const markers = {markers_json}; const markers = {markers_json};
+1 -1
View File
@@ -15,7 +15,7 @@
open, openTab, peek, folder, rename, setIcon, duplicate, link, download, open, openTab, peek, folder, rename, setIcon, duplicate, link, download,
copyContent, move, fav, recent, delete, tagExisting, tagAdd, tagRemove copyContent, move, fav, recent, delete, tagExisting, tagAdd, tagRemove
############################################################################} ############################################################################}
<script data-cfasync="false"> <script data-cfasync="false" nonce="{{ csp_nonce() }}">
/* ═════════════════════════════════════════════════════════════════════ /* ═════════════════════════════════════════════════════════════════════
fdCtx — store Alpine UNIQUE du menu contextuel partagé. fdCtx — store Alpine UNIQUE du menu contextuel partagé.
Enregistré dans 'alpine:init' avec garde d'exécution : que la page Enregistré dans 'alpine:init' avec garde d'exécution : que la page
+1 -1
View File
@@ -106,7 +106,7 @@
.db-list-title{flex:1;min-width:120px} .db-list-title{flex:1;min-width:120px}
.db-list-cell{color:var(--text-secondary);font-size:12px;min-width:110px} .db-list-cell{color:var(--text-secondary);font-size:12px;min-width:110px}
</style> </style>
<script data-cfasync="false"> <script data-cfasync="false" nonce="{{ csp_nonce() }}">
(function() { (function() {
'use strict'; 'use strict';
+2 -2
View File
@@ -47,7 +47,7 @@
</button> </button>
{% endif %} {% endif %}
<script type="application/json" id="fd-breadcrumb-data">{{ {"workspace_id": nav_workspace_id|default(0), "crumbs": ns.items}|tojson }}</script> <script type="application/json" id="fd-breadcrumb-data" nonce="{{ csp_nonce() }}">{{ {"workspace_id": nav_workspace_id|default(0), "crumbs": ns.items}|tojson }}</script>
<div class="topbar-left header-breadcrumb" x-data="fdBreadcrumb()" x-init="init()" <div class="topbar-left header-breadcrumb" x-data="fdBreadcrumb()" x-init="init()"
@mouseleave="dragCloseTimer()"> @mouseleave="dragCloseTimer()">
@@ -142,7 +142,7 @@
</div> </div>
</header> </header>
<script data-cfasync="false"> <script data-cfasync="false" nonce="{{ csp_nonce() }}">
document.addEventListener('alpine:init', function () { document.addEventListener('alpine:init', function () {
if (window.Alpine && window.Alpine.__fdBreadcrumbRegistered) return; if (window.Alpine && window.Alpine.__fdBreadcrumbRegistered) return;
if (window.Alpine) window.Alpine.__fdBreadcrumbRegistered = true; if (window.Alpine) window.Alpine.__fdBreadcrumbRegistered = true;
+2 -2
View File
@@ -10,7 +10,7 @@
############################################################################} ############################################################################}
{% set picker_icons = ['folder','file','calendar','clock','star','bot','users','globe','lock','book','check-square','trash','help-circle','settings','refresh','log-out','message-square','home','search','link','plus','bell','image','download','list','bar-chart','grid','align-left','corner-down-right','copy','key','inbox','edit','eye','share','x','paperclip','external-link','sparkles','lightbulb','tag','file-text','save','upload','trending-up','zap','alert-triangle','user'] %} {% set picker_icons = ['folder','file','calendar','clock','star','bot','users','globe','lock','book','check-square','trash','help-circle','settings','refresh','log-out','message-square','home','search','link','plus','bell','image','download','list','bar-chart','grid','align-left','corner-down-right','copy','key','inbox','edit','eye','share','x','paperclip','external-link','sparkles','lightbulb','tag','file-text','save','upload','trending-up','zap','alert-triangle','user'] %}
<script data-cfasync="false"> <script data-cfasync="false" nonce="{{ csp_nonce() }}">
/* Global JS mirror of _icons.html + helpers to render emoji / icon / custom-emoji URLs. */ /* Global JS mirror of _icons.html + helpers to render emoji / icon / custom-emoji URLs. */
(function () { (function () {
var FD_ICONS = { var FD_ICONS = {
@@ -89,7 +89,7 @@
})(); })();
</script> </script>
<script data-cfasync="false"> <script data-cfasync="false" nonce="{{ csp_nonce() }}">
(function () { (function () {
if (window.__fdIconPickerRegistered) return; if (window.__fdIconPickerRegistered) return;
window.__fdIconPickerRegistered = true; window.__fdIconPickerRegistered = true;
+1 -1
View File
@@ -61,7 +61,7 @@
</div> </div>
</span> </span>
<script data-cfasync="false"> <script data-cfasync="false" nonce="{{ csp_nonce() }}">
document.addEventListener('alpine:init', function () { document.addEventListener('alpine:init', function () {
if (window.Alpine && window.Alpine.__fdNotificationsRegistered) return; if (window.Alpine && window.Alpine.__fdNotificationsRegistered) return;
if (window.Alpine) window.Alpine.__fdNotificationsRegistered = true; if (window.Alpine) window.Alpine.__fdNotificationsRegistered = true;
+1 -1
View File
@@ -9,7 +9,7 @@
.rt-cursor .rt-cursor-name{position:absolute;top:-14px;left:4px;white-space:nowrap;font-size:9px;line-height:12px;padding:0 4px;border-radius:4px;color:#fff;font-weight:600;letter-spacing:.2px;} .rt-cursor .rt-cursor-name{position:absolute;top:-14px;left:4px;white-space:nowrap;font-size:9px;line-height:12px;padding:0 4px;border-radius:4px;color:#fff;font-weight:600;letter-spacing:.2px;}
.rt-offline{font-size:11px;color:var(--text-tertiary,#999);margin-right:6px;display:none;} .rt-offline{font-size:11px;color:var(--text-tertiary,#999);margin-right:6px;display:none;}
</style> </style>
<script data-cfasync="false"> <script data-cfasync="false" nonce="{{ csp_nonce() }}">
/* eslint-disable */ /* eslint-disable */
/* ═══════════ v5.13.0 Realtime — WS gateway, présence, curseurs, merge LWW ═══════════ */ /* ═══════════ v5.13.0 Realtime — WS gateway, présence, curseurs, merge LWW ═══════════ */
window.__fdRT = (function () { window.__fdRT = (function () {
+2 -2
View File
@@ -1,7 +1,7 @@
<script type="application/json" id="page-data"> <script type="application/json" id="page-data" nonce="{{ csp_nonce() }}">
{{ page_data | tojson }} {{ page_data | tojson }}
</script> </script>
<script data-cfasync="false"> <script data-cfasync="false" nonce="{{ csp_nonce() }}">
/* eslint-disable */ /* eslint-disable */
if (!window.__fdEditorScriptsLoaded) { if (!window.__fdEditorScriptsLoaded) {
window.__fdEditorScriptsLoaded = true; window.__fdEditorScriptsLoaded = true;
+1 -1
View File
@@ -104,7 +104,7 @@
{% endblock %} {% endblock %}
{% block scripts %} {% block scripts %}
<script data-cfasync="false"> <script data-cfasync="false" nonce="{{ csp_nonce() }}">
function accountsData() { function accountsData() {
return { return {
profile: { full_name: '', email: '' }, profile: { full_name: '', email: '' },
+2 -2
View File
@@ -204,7 +204,7 @@
</style> </style>
{# ── Global Agent API (available before Alpine for the FAB / shortcut) ── #} {# ── Global Agent API (available before Alpine for the FAB / shortcut) ── #}
<script data-cfasync="false"> <script data-cfasync="false" nonce="{{ csp_nonce() }}">
(function(){ (function(){
var defaultAPI = { var defaultAPI = {
open: function(){ document.dispatchEvent(new CustomEvent('fd-agent-toggle')); }, open: function(){ document.dispatchEvent(new CustomEvent('fd-agent-toggle')); },
@@ -456,7 +456,7 @@
</div> </div>
</div> </div>
<script data-cfasync="false"> <script data-cfasync="false" nonce="{{ csp_nonce() }}">
(function(){ (function(){
// ── Skills intégrés (workflows « / ») et commandes admin du panneau ── // ── Skills intégrés (workflows « / ») et commandes admin du panneau ──
var FD_ADMIN_CMDS = [ var FD_ADMIN_CMDS = [
+7 -4
View File
@@ -113,6 +113,9 @@
body.embed-mode .page-editor-wrapper { padding: 8px 16px !important; max-width: 100% !important; } body.embed-mode .page-editor-wrapper { padding: 8px 16px !important; max-width: 100% !important; }
body.embed-mode .page-cover-area { padding-top: 0 !important; } body.embed-mode .page-cover-area { padding-top: 0 !important; }
</style> </style>
{# A20 : htmx copie les <script nonce="{{ csp_nonce() }}"> des réponses boostées — il remet leur nonce
depuis cette config (le nonce de la réponse courante, pas celui du fetch). #}
<meta name="htmx-config" content='{"inlineScriptNonce": "{{ csp_nonce() }}"}'>
<script src="/static/js/htmx.min.js" data-cfasync="false"></script> <script src="/static/js/htmx.min.js" data-cfasync="false"></script>
<script src="/static/js/alpine.min.js" defer data-cfasync="false"></script> <script src="/static/js/alpine.min.js" defer data-cfasync="false"></script>
<script src="/static/js/sortable.min.js" defer data-cfasync="false"></script> <script src="/static/js/sortable.min.js" defer data-cfasync="false"></script>
@@ -790,7 +793,7 @@
</div> </div>
<script data-cfasync="false"> <script data-cfasync="false" nonce="{{ csp_nonce() }}">
// Inject CSRF token into HTMX headers // Inject CSRF token into HTMX headers
(function() { (function() {
const getCsrf = () => { const getCsrf = () => {
@@ -2207,7 +2210,7 @@
</div> </div>
</div> </div>
</div> </div>
<script data-cfasync="false"> <script data-cfasync="false" nonce="{{ csp_nonce() }}">
(function(){ (function(){
var PALETTE_ACTIONS = [ var PALETTE_ACTIONS = [
{ id:'new-page', icon:'📄', title:'New page', sub:'Create a new page in the current workspace', key:'Ctrl N', run:function(){ return window.FlowDeck && window.FlowDeck.createPage ? (window.FlowDeck.createPage(), true) : false; } }, { id:'new-page', icon:'📄', title:'New page', sub:'Create a new page in the current workspace', key:'Ctrl N', run:function(){ return window.FlowDeck && window.FlowDeck.createPage ? (window.FlowDeck.createPage(), true) : false; } },
@@ -2378,7 +2381,7 @@
{# ─── PWA: offline client module + service worker registration (v6.0.0) ─── #} {# ─── PWA: offline client module + service worker registration (v6.0.0) ─── #}
<script src="/static/js/offline.js?v={{ asset_version }}" defer data-cfasync="false"></script> <script src="/static/js/offline.js?v={{ asset_version }}" defer data-cfasync="false"></script>
<script data-cfasync="false"> <script data-cfasync="false" nonce="{{ csp_nonce() }}">
(function() { (function() {
if (!('serviceWorker' in navigator)) return; if (!('serviceWorker' in navigator)) return;
window.addEventListener('load', function() { window.addEventListener('load', function() {
@@ -2392,7 +2395,7 @@
</script> </script>
{# ─── PWA: sync badge + toasts wiring (v6.0.0) ─── #} {# ─── PWA: sync badge + toasts wiring (v6.0.0) ─── #}
<script data-cfasync="false"> <script data-cfasync="false" nonce="{{ csp_nonce() }}">
document.addEventListener('DOMContentLoaded', function() { document.addEventListener('DOMContentLoaded', function() {
if (!window.FlowOffline) return; if (!window.FlowOffline) return;
window.FlowOffline.onChange(function(s) { window.FlowOffline.onChange(function(s) {
+1 -1
View File
@@ -151,7 +151,7 @@
{% endblock %} {% endblock %}
{% block scripts %} {% block scripts %}
<script data-cfasync="false"> <script data-cfasync="false" nonce="{{ csp_nonce() }}">
var owner = '{{ owner }}'; var owner = '{{ owner }}';
var repo = '{{ repo }}'; var repo = '{{ repo }}';
var initialView = '{{ initial_view }}'; var initialView = '{{ initial_view }}';
+1 -1
View File
@@ -49,7 +49,7 @@
{% endfor %} {% endfor %}
</div> </div>
<script> <script nonce="{{ csp_nonce() }}">
function openCardDetail(id) { function openCardDetail(id) {
htmx.ajax('GET', `/api/issues/${owner}/${repo}/${id}?format=html`, { htmx.ajax('GET', `/api/issues/${owner}/${repo}/${id}?format=html`, {
target: '#card-modal-content', swap: 'innerHTML' target: '#card-modal-content', swap: 'innerHTML'
+1 -1
View File
@@ -98,7 +98,7 @@
</div> </div>
</div> </div>
<script> <script nonce="{{ csp_nonce() }}">
// ponytail: CSRF helper // ponytail: CSRF helper
function getCsrf() { function getCsrf() {
const m = document.cookie.match(/csrf_token=([^;]+)/); const m = document.cookie.match(/csrf_token=([^;]+)/);
+1 -1
View File
@@ -58,7 +58,7 @@
{% endfor %} {% endfor %}
</div> </div>
<script> <script nonce="{{ csp_nonce() }}">
function openCardDetail(id) { function openCardDetail(id) {
htmx.ajax('GET', `/api/issues/${owner}/${repo}/${id}?format=html`, { htmx.ajax('GET', `/api/issues/${owner}/${repo}/${id}?format=html`, {
target: '#card-modal-content', swap: 'innerHTML' target: '#card-modal-content', swap: 'innerHTML'
+1 -1
View File
@@ -35,7 +35,7 @@
.gw-content pre[class*="language-"]{background:var(--bg-tertiary)!important;border:1px solid var(--border)!important;border-radius:8px!important;padding:16px!important;} .gw-content pre[class*="language-"]{background:var(--bg-tertiary)!important;border:1px solid var(--border)!important;border-radius:8px!important;padding:16px!important;}
</style> </style>
<script data-cfasync="false"> <script data-cfasync="false" nonce="{{ csp_nonce() }}">
document.addEventListener('alpine:init', () => { document.addEventListener('alpine:init', () => {
Alpine.data('giteaWorkspace', () => { Alpine.data('giteaWorkspace', () => {
const params = new URLSearchParams(window.location.search); const params = new URLSearchParams(window.location.search);
+1 -1
View File
@@ -210,7 +210,7 @@ select,input[type=text]{background:var(--bg3);color:var(--text);border:1px solid
</div> </div>
</div> </div>
<script data-cfasync="false"> <script data-cfasync="false" nonce="{{ csp_nonce() }}">
function importWizard() { function importWizard() {
return { return {
sources: [], sources: [],
+1 -1
View File
@@ -206,7 +206,7 @@
FlowDeck v4.0.1 — Open source · Self-hosted · Notion-compatible FlowDeck v4.0.1 — Open source · Self-hosted · Notion-compatible
</footer> </footer>
<script data-cfasync="false"> <script data-cfasync="false" nonce="{{ csp_nonce() }}">
(function() { (function() {
if (!('serviceWorker' in navigator)) return; if (!('serviceWorker' in navigator)) return;
window.addEventListener('load', function() { window.addEventListener('load', function() {
+1 -1
View File
@@ -456,7 +456,7 @@
{% endblock %} {% endblock %}
{% block scripts %} {% block scripts %}
<script data-cfasync="false"> <script data-cfasync="false" nonce="{{ csp_nonce() }}">
function libraryPage() { function libraryPage() {
return { return {
tab: 'recents', tab: 'recents',
+2 -2
View File
@@ -480,7 +480,7 @@
.preview-leave-end{transform:translateX(100%);opacity:0;} .preview-leave-end{transform:translateX(100%);opacity:0;}
</style> </style>
<script data-cfasync="false"> <script data-cfasync="false" nonce="{{ csp_nonce() }}">
console.log('FLOWDECK v2.4.6 — IIFE running, about to set _wsInitData'); console.log('FLOWDECK v2.4.6 — IIFE running, about to set _wsInitData');
window._wsInitData = (function() { window._wsInitData = (function() {
return { return {
@@ -2512,7 +2512,7 @@ for (var _i = 0; _i < _wsKeys.length; _i++) {
_wsInitData = window._wsInitData; _wsInitData = window._wsInitData;
console.log('FLOWDECK v2.4.6 — IIFE done, _wsInitData keys:', Object.keys(window._wsInitData).length); console.log('FLOWDECK v2.4.6 — IIFE done, _wsInitData keys:', Object.keys(window._wsInitData).length);
</script> </script>
<script data-cfasync="false">console.log('FLOWDECK v2.4.6 — ws-split about to render, _wsInitData exists:', typeof window._wsInitData !== 'undefined');</script> <script data-cfasync="false" nonce="{{ csp_nonce() }}">console.log('FLOWDECK v2.4.6 — ws-split about to render, _wsInitData exists:', typeof window._wsInitData !== 'undefined');</script>
<div class="ws-split" <div class="ws-split"
@contextmenu.prevent="_wsInitData.onContextMenu($event)" @contextmenu.prevent="_wsInitData.onContextMenu($event)"
+1 -1
View File
@@ -7,7 +7,7 @@ page_title %}{{ page.title }}{% endblock %} {% block topbar %}
{% endblock %} {% block content %} {% endblock %} {% block content %}
{% include "_database_table.html" %} {% include "_database_table.html" %}
{% endblock %} {% block scripts %} {% endblock %} {% block scripts %}
<script data-cfasync="false"> <script data-cfasync="false" nonce="{{ csp_nonce() }}">
// Initialize database table from server-rendered data // Initialize database table from server-rendered data
window.__DB_PAGE_ID = {{ page.id }}; window.__DB_PAGE_ID = {{ page.id }};
window.__DB_COLLECTION_ID = {{ page.collection_id or 0 }}; window.__DB_COLLECTION_ID = {{ page.collection_id or 0 }};
+1 -1
View File
@@ -4,7 +4,7 @@
{% block topbar %}{% endblock %} {% block topbar %}{% endblock %}
{% block content %} {% block content %}
{% include '_page_editor_content.html' %} {% include '_page_editor_content.html' %}
<script data-cfasync="false">document.body.classList.add('embed-mode');</script> <script data-cfasync="false" nonce="{{ csp_nonce() }}">document.body.classList.add('embed-mode');</script>
{% endblock %} {% endblock %}
{% block scripts %} {% block scripts %}
{% include '_page_editor_scripts.html' %} {% include '_page_editor_scripts.html' %}
+1 -1
View File
@@ -173,7 +173,7 @@
</footer> </footer>
<script src="/static/js/katex.min.js?v=0.16.11"></script> <script src="/static/js/katex.min.js?v=0.16.11"></script>
<script data-cfasync="false"> <script data-cfasync="false" nonce="{{ csp_nonce() }}">
document.addEventListener('DOMContentLoaded', function () { document.addEventListener('DOMContentLoaded', function () {
if (window.katex) { if (window.katex) {
document.querySelectorAll('div[data-katex]').forEach(function (el) { document.querySelectorAll('div[data-katex]').forEach(function (el) {
+1 -1
View File
@@ -1191,7 +1191,7 @@
</div> </div>
</div> </div>
<script data-cfasync="false"> <script data-cfasync="false" nonce="{{ csp_nonce() }}">
function settingsInit() { function settingsInit() {
return { return {
activeSection: 'account', activeSection: 'account',
+1 -1
View File
@@ -99,7 +99,7 @@
</div> </div>
</div> </div>
<script> <script nonce="{{ csp_nonce() }}">
function tableView() { function tableView() {
return { return {
sortField: '', sortField: '',
+1 -1
View File
@@ -43,7 +43,7 @@
<div class="legend-item"><span class="legend-dot" style="background:var(--green);"></span> Complete</div> <div class="legend-item"><span class="legend-dot" style="background:var(--green);"></span> Complete</div>
</div> </div>
<script> <script nonce="{{ csp_nonce() }}">
function teamLoad() { function teamLoad() {
return {}; return {};
} }
+1 -1
View File
@@ -62,7 +62,7 @@
{% endblock %} {% endblock %}
{% block scripts %} {% block scripts %}
<script data-cfasync="false"> <script data-cfasync="false" nonce="{{ csp_nonce() }}">
function trashData() { function trashData() {
return { return {
search: '', search: '',
+1 -1
View File
@@ -133,7 +133,7 @@ h1{font-size:22px;font-weight:700;margin-bottom:6px;}
</section> </section>
</div> </div>
<script data-cfasync="false"> <script data-cfasync="false" nonce="{{ csp_nonce() }}">
function onboarding() { function onboarding() {
return { return {
steps: ['Bienvenue', 'Espace de travail', 'Connecter une forge', 'Premier projet'], steps: ['Bienvenue', 'Espace de travail', 'Connecter une forge', 'Premier projet'],
+1 -1
View File
@@ -140,7 +140,7 @@
</div> </div>
<script data-cfasync="false"> <script data-cfasync="false" nonce="{{ csp_nonce() }}">
function workspacePage() { function workspacePage() {
return { return {
builtinProjects: [], builtinProjects: [],
+1 -1
View File
@@ -172,7 +172,7 @@
</div> </div>
<script data-cfasync="false"> <script data-cfasync="false" nonce="{{ csp_nonce() }}">
function workspacesPage() { function workspacesPage() {
return { return {
workspaces: [], workspaces: [],
+7
View File
@@ -7,9 +7,15 @@ les `|safe` du codebase étaient des no-op.
from __future__ import annotations from __future__ import annotations
import pathlib import pathlib
from contextvars import ContextVar
from jinja2 import Environment, FileSystemLoader, select_autoescape from jinja2 import Environment, FileSystemLoader, select_autoescape
# A20 : nonce de script par requête, posé par le middleware CSP, lu par les
# templates via `{{ csp_nonce() }}` (vide hors requête — pas de header CSP
# dans ce cas, donc rien n'est bloqué).
CSP_NONCE: ContextVar[str] = ContextVar("csp_nonce", default="")
ENV = Environment( ENV = Environment(
loader=FileSystemLoader("app/templates"), loader=FileSystemLoader("app/templates"),
autoescape=select_autoescape(["html"]), autoescape=select_autoescape(["html"]),
@@ -26,3 +32,4 @@ except OSError: # pragma: no cover
ASSET_VERSION = "dev" ASSET_VERSION = "dev"
ENV.globals["asset_version"] = ASSET_VERSION ENV.globals["asset_version"] = ASSET_VERSION
ENV.globals["csp_nonce"] = lambda: CSP_NONCE.get()
+1 -1
View File
@@ -2,7 +2,7 @@
"openapi": "3.1.0", "openapi": "3.1.0",
"info": { "info": {
"title": "FlowDeck", "title": "FlowDeck",
"version": "7.6.0" "version": "7.7.0"
}, },
"paths": { "paths": {
"/auth/register": { "/auth/register": {
+55
View File
@@ -215,6 +215,61 @@ def test_columns_helper_validates_table_name():
conn.close() conn.close()
def _assert_nonce(csp: str, html: str) -> str:
"""Header CSP : script-src sans unsafe-inline + TOUS les scripts inline noncés."""
import re as _re
m = _re.search(r"script-src ([^;]*);", csp)
assert m, csp
script_src = m.group(1)
nm = _re.search(r"'nonce-([^']+)'", script_src)
assert nm, script_src
nonce = nm.group(1)
assert "'unsafe-inline'" not in script_src, script_src
assert "'unsafe-eval'" in script_src # Alpine/htmx — reste d'A20
assert "script-src-attr 'unsafe-inline'" in csp
tags = [
mm.group(0)
for mm in _re.finditer(r"<script[^>]*>", html)
if "src=" not in mm.group(0)
]
assert tags, "aucun script inline"
missing = [t for t in tags if f'nonce="{nonce}"' not in t]
assert missing == [], missing[:3]
return nonce
def test_csp_nonce_per_request(client):
"""A20 : nonce par requête — page base.html (avec meta htmx-config) et page
hors template (LOCAL_LOGIN_HTML, constante de module → nonce au rendu)."""
# 1) une page qui étend base.html (la meta htmx-config y est)
base = None
for url in ("/", "/dashboard", "/board", "/notes", "/settings", "/workspaces"):
cand = client.get(url)
if cand.status_code == 200 and "htmx-config" in cand.text:
base = cand
break
assert base is not None, "aucune page base.html atteignable"
nonce = _assert_nonce(base.headers.get("content-security-policy", ""), base.text)
assert f'"inlineScriptNonce": "{nonce}"' in base.text
# deux requêtes = deux nonces différents
other = client.get("/dashboard")
if other.status_code == 200 and "htmx-config" in other.text:
other_nonce = _re_search_nonce(other.headers.get("content-security-policy", ""))
assert other_nonce != nonce
# 2) la page de login hors template (script injecté par _with_nonce)
r = client.get("/auth/login?provider=local")
assert r.status_code == 200, r.status_code
_assert_nonce(r.headers.get("content-security-policy", ""), r.text)
def _re_search_nonce(csp: str) -> str:
import re as _re
return _re.search(r"'nonce-([^']+)'", _re.search(r"script-src ([^;]*);", csp).group(1)).group(1)
def test_no_duplicate_routes(): def test_no_duplicate_routes():
"""A24 : deux routes même méthode+chemin → l'une écrase silencieusement l'autre.""" """A24 : deux routes même méthode+chemin → l'une écrase silencieusement l'autre."""
from app.main import app from app.main import app