fix: A20 (partiel) — CSP nonce par requête, unsafe-inline sort de script-src (v7.7.0)
- 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:
@@ -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
@@ -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
@@ -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
@@ -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,
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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};
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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 () {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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: '' },
|
||||||
|
|||||||
@@ -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 = [
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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 }}';
|
||||||
|
|||||||
@@ -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'
|
||||||
|
|||||||
@@ -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=([^;]+)/);
|
||||||
|
|||||||
@@ -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'
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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: [],
|
||||||
|
|||||||
@@ -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() {
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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)"
|
||||||
|
|||||||
@@ -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 }};
|
||||||
|
|||||||
@@ -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' %}
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -99,7 +99,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script>
|
<script nonce="{{ csp_nonce() }}">
|
||||||
function tableView() {
|
function tableView() {
|
||||||
return {
|
return {
|
||||||
sortField: '',
|
sortField: '',
|
||||||
|
|||||||
@@ -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 {};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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: '',
|
||||||
|
|||||||
@@ -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'],
|
||||||
|
|||||||
@@ -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: [],
|
||||||
|
|||||||
@@ -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,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()
|
||||||
|
|||||||
@@ -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": {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user