feat: A20 — htmx allowEval off + plan Alpine CSP phase 3 scopé par probes (v7.37.0)
FlowDeck CI / docker (push) Successful in 1m49s
FlowDeck CI / lint (push) Successful in 1m55s
FlowDeck CI / test (push) Successful in 15m4s

Changed :
- htmx `allowEval: false` dans le meta htmx-config (base.html) : plus
  d'évaluation JS côté htmx (hx-on/hx-vars/hx-vals = 0 usage grep → zéro
  régression possible) ; unsafe-eval reste UNIQUEMENT pour Alpine standard.
- Gate E20 renforcée : le smoke vérifie que `Alpine.$data()` lie un vrai
  composant [x-data] de la page (lien composant = cœur de toute bascule CSP).
- sw.js : cache bump flowdeck-v7 (purge + re-precache après Inter).

Probes (non conservés, retirés après mesure) — A20 phase 3 scopée :
- Build `@alpinejs/csp` téléchargé et TESTÉ : 72 Ko, 0 eval/new Function,
  parseur d'expressions maison, tourne sous CSP strict (meta sans
  unsafe-eval) — le lint sélectif fonctionne.
- Mais bloqué sur FlowDeck :
  (a) 13 expressions non parsables par la grammaire restreinte
      (arrows ×2, typeof ×1, new Date ×4, optional-chaining ×6 ;
       base, library, local_workspace, settings, gitea_workspace) —
      le gate E2E a attrapé la première : `CSP Parser Error: Unexpected
      token: PUNCTUATION ")"` ;
  (b) 24 `x-html` réactifs (icônes SVG + markdown agent + preview) =
      INTERDITS par le build CSP (innerHTML) → architecture d'icônes à
      reposer ;
  (c) scope des expressions CSP = données du composant uniquement
      (probe : `Undefined variable: fmtDate` / `document`) → chaque site
      devient une méthode Alpine.data enregistrée.
- Conséquence : build CSP reverté (alpine.min.js ×3 templates + sw),
  unsafe-eval maintenu, fichier alpine.csp.min.js retiré (re-téléchargeable),
  assert test CSP de nouveau `in`. Plan de migration composant par composant
  (library → settings → local_workspace → gitea → base) + gate E2E par
  surface documenté dans ROADMAP (A20 phase 3).

suite **1093/1093** · ruff OK · E2E **2/2** (dont assertion Alpine.$data)
· docs à jour (ROADMAP A20 phase 3, CHANGELOG, WORKLOAD, VERSION)
This commit is contained in:
2026-10-02 11:14:47 -04:00
parent ab6ac1e84c
commit 840d2b2615
11 changed files with 61 additions and 13 deletions
+31
View File
@@ -1,5 +1,36 @@
# Changelog - FlowDeck
## v7.37.0 (2026-10-01) — A20 : htmx allowEval off + plan Alpine CSP (phase 3)
### Changed
- **htmx `allowEval: false`** dans le `<meta name="htmx-config">` : htmx
ne peut plus évaluer de JS (`hx-on`/`hx-vars`/`hx-vals`) — grep = **0
usage** dans les templates, donc zéro régression possible. `unsafe-eval`
reste **uniquement** pour Alpine standard.
- **Gate E20 renforcé** : le smoke vérifie désormais que `Alpine.$data()`
lie un vrai composant `[x-data]` de la page (le lien composant = le cœur
de tout basculement CSP).
### Notes — A20 phase 3 (unsafe-eval, scopé par probes)
Le build `@alpinejs/csp` a été **testé empiriquement** (fichier 72 Ko,
**0 `eval`/`new Function`**, parseur d'expressions maison) : il tourne sous
CSP strict, mais **bloqué sur FlowDeck** par deux familles d'usages :
- **13 expressions non parsables** par la grammaire restreinte :
arrows (`$nextTick(() => …)` ×2), `typeof` ×1, `new Date(…)` ×4,
optional-chaining `?.` ×6 (base, library, local_workspace, settings,
gitea_workspace) ;
- **24 `x-html` réactifs** (icônes SVG `getSvgIcon`/`_fileIcon`,
markdown agent, preview) — **interdits par le build CSP** (innerHTML) :
nécessitent de reposer les icônes sur des composants `Alpine.data`.
- Portée : 0 variable globale/`document` accessible dans les expressions
du build CSP (scope = données du composant + magics) → chaque site
devient une méthode de composant enregistrée via `Alpine.data`.
**Plan phase 3** : migrer composant par composant (library → settings →
local_workspace → gitea → base) avec gate E2E dédiée, puis retirer
`unsafe-eval`. En attendant : `unsafe-eval` conservé (Alpine standard).
## v7.36.0 (2026-10-01) — Fondations E2E + 2 bugs trouvés au passage
### Added
+1 -1
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] **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] **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). **Phase 2 faite 2026-10-01** : **CDN vendorisé + connect-src fermé**. chart.js 4.5.1, leaflet 1.9 (js/css + 5 png) téléchargés vers `static/js/vendor/` (déjà ignoré par eslint) ; les 3 URL de `collections.py` pointent en local ; **`script-src` n'a plus aucun hôte tiers** (jsdelivr/unpkg retirés), idem `style-src` ; **`connect-src` = `'self' ws://{host} wss://{host}`** (Host de la requête, caractères filtrés — uvicorn rejette déjà les Host invalides) : le `https:` universel (canal d'exfil) et les `ws:`/`wss:` tout-hôtes disparaissent ; grep négatif = **0 fetch cross-origin côté front** ; Google Fonts étaient **morts** dans la CSP (0 ref) → retirés ; `img-src https:` **gardé volontairement** (unfurls YouTube/Vimeo… + tuiles OSM inénumérables, `ponytail:` commenté dans le code). Test `test_csp_no_cdn_and_vendor` (CSP sans CDN, connect-src exact, 4 assets vendor 200, source collections.py) + `test_view_chart_renders` mis à jour (vendor path) → 1090/1090. **Reste A20** : `unsafe-eval` (Alpine x-data → build `@alpinejs/csp` **+** htmx `hx-on`/`hx-vars` utilisent `Function`/`eval` → les deux ensemble, **nécessite d'abord une couverture E2E des vues** — décision comme A39), `img-src` si un proxy d'images local arrive. Effort : **L** (reste : L + E20).
- [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). **Phase 2 faite 2026-10-01** : **CDN vendorisé + connect-src fermé**. chart.js 4.5.1, leaflet 1.9 (js/css + 5 png) téléchargés vers `static/js/vendor/` (déjà ignoré par eslint) ; les 3 URL de `collections.py` pointent en local ; **`script-src` n'a plus aucun hôte tiers** (jsdelivr/unpkg retirés), idem `style-src` ; **`connect-src` = `'self' ws://{host} wss://{host}`** (Host de la requête, caractères filtrés — uvicorn rejette déjà les Host invalides) : le `https:` universel (canal d'exfil) et les `ws:`/`wss:` tout-hôtes disparaissent ; grep négatif = **0 fetch cross-origin côté front** ; Google Fonts étaient **morts** dans la CSP (0 ref) → retirés ; `img-src https:` **gardé volontairement** (unfurls YouTube/Vimeo… + tuiles OSM inénumérables, `ponytail:` commenté dans le code). Test `test_csp_no_cdn_and_vendor` (CSP sans CDN, connect-src exact, 4 assets vendor 200, source collections.py) + `test_view_chart_renders` mis à jour (vendor path) → 1090/1090. **Reste A20 → phase 3 scopée par probes (2026-10-01)** : htmx = **FINI** (`allowEval:false` dans le meta htmx-config — 0 `hx-on`/`hx-vars`/`hx-vals` grep) ; **`unsafe-eval` reste uniquement pour Alpine standard**. Le build `@alpinejs/csp` a été téléchargé et **testé** (0 `eval`/`new Function`, parseur maison, tourne sous CSP strict) mais est **bloqué** sur FlowDeck : (a) **13 expressions non parsables** par la grammaire restreinte (arrows ×2, `typeof` ×1, `new Date` ×4, optional-chaining ×6 — base, library, local_workspace, settings, gitea_workspace), (b) **24 `x-html` réactifs** (icônes SVG `getSvgIcon`/`_fileIcon`, markdown agent, preview) = **interdits** par le build CSP (innerHTML), (c) le scope des expressions CSP = **données du composant uniquement** (0 variable globale ni `document` — probe : `Undefined variable: fmtDate`) → chaque site devient une méthode enregistrée via `Alpine.data`. **Plan** : migration composant par composant (library → settings → local_workspace → gitea → base) avec gate E2E dédiée par surface, puis retrait `unsafe-eval`. `img-src` : si un proxy d'images local arrive. Effort : **L** (reste : L, plan ci-dessus).
- [x] **A21 — `sqlite3` synchrone sur l'event loop — PARTIEL 2026-09-30 → phase 1 le 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`.* — **phase 1 faite 2026-10-01** : **352 routes `async def` SANS aucun `await`** converties en `def` (scan corps par corps : ni `await`/`async with`/`async for`, ni `asyncio`) → FastAPI les exécute dans son threadpool, donc tout leur travail SQLite quitte l'event loop, sans changer une ligne de logique (api_v2 : 60, dashboard : 40, collections : 25, board : 23, + main.py : 6 ; aucune occurrence `asyncio`/`run_coroutine` dans les corps convertis). **Phase 2a faite 2026-10-01 (api_v2)** : les routes dont le SEUL await était `body = await request.json()` (36) → paramètre FastAPI `body: dict = Body(default={})` (parsing fait par FastAPI avant l'appel, équivalences vérifiées : corps absent → `{}` comme le try/except d'avant, JSON invalide → 422 au lieu d'un silencieux `{}`) + conversion en `def` → **api_v2 passe à 96/115 routes hors loop**. **Phase 2b faite 2026-10-01 (api_v2 bouclé)** : helper `run_event_sync(coro)` (automations.py) — `asyncio.run` sur une boucle dédiée dans le worker threadpool : l'événement est EXÉCUTÉ ET ATTENDU avant la réponse (déterministe comme l'await) mais ne bloque jamais la boucle ; les 15 routes dont les seuls awaits étaient `json`/`_fire_event`/`fire_published`/`fire_unpublished` passent en `def`. **api_v2 : 111/115 routes hors loop**, les 4 restantes ont de vrais awaits réseau (`import_csv_v2` multipart, `project_tree_v2` (gitea), `test_webhook_v2`, `retry_webhook_deliveries`). **Phase 2c faite 2026-10-01 (repo-wide)** : **283 → 93 routes async** (**86 % des 667 routes hors loop**, avant 61 %) en 4 passes — (A) racine auth : `get_current_user` (session.py) + ses clones async (`agent._current_user_id/_workspace_id/_current_admin`, `sso._require_admin`) étaient `async` **sans aucun await** → `def`, **47 `await` supprimés** dont 3 via l'alias `gcu` (piège : grep littéral aveugle, rattrapé par la suite) ; (B) re-scan → 19 routes flipées ; (C/D) **155 routes** `request.json`/événements → `Body(default={})` (3 formes : try/except `body = {}` intact, try/except `raise HTTPException(400)` → `Body(...)` requis (422 FastAPI, **0 test sur le 400**), forme conditionnelle content-type ×54 → défaut `{}`) + `run_event_sync` → `def`. **Reste async (93, justifié)** : form/upload/file (22), gitea/llm/oidc réseau (~25), `_json_body` 9, 2 JSON inline en argument, 1 fallback logique, 1 lecture conditionnelle web_clipper, + mixtes json+réseau. **A21 : fait** (sauf l'idée initiale d'`anyio.to_thread` par bloc DB — **inutile** : les routes sont DÉJÀ hors loop, le SQLite synchrone n'est plus sur la boucle). Effort : **L** (fait).
- [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**.*
+1 -1
View File
@@ -1 +1 @@
7.36.0
7.37.0
+1 -1
View File
@@ -1,6 +1,6 @@
# WORKLOAD — FlowDeck Notion Clone
> **Début**: 2026-07-08 | **Version**: v7.36.0 (E2E fondations : smoke A39/A20 vert + 2 bugs trouvés (onglets ?view=, Inter CSP) | **Statut**: EN COURS 🔄
> **Début**: 2026-07-08 | **Version**: v7.37.0 (A20 htmx allowEval off + probe Alpine CSP = plan phase 3 (13 exprs + 24 x-html)) | **Statut**: EN COURS 🔄
> **Cible**: parité Notion + intégration forge · **Follow-ups v7.3 livrés**: sidebar teamspaces, notif `page.updated`, charts `number` + dashboards multi-DB, unfurl forge, UI Settings → Audit — voir `ROADMAP.md § v7.3.0`
## Avancement Global
+1 -1
View File
@@ -185,7 +185,7 @@ async def lifespan(_app: FastAPI):
app = FastAPI(
title="FlowDeck",
version="7.36.0",
version="7.37.0",
docs_url="/docs",
redoc_url="/redoc",
lifespan=lifespan,
+6 -4
View File
@@ -71,14 +71,16 @@ class ContentSecurityPolicyMiddleware(BaseHTTPMiddleware):
# 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).
# `unsafe-eval` : Alpine STANDARD (x-data) en a besoin. htmx n'y touche
# plus (`allowEval: false` dans le meta htmx-config — 0 hx-on/hx-vars).
# Retrait = A20 phase 3 : build `@alpinejs/csp` (testé : 0 eval, OK sur
# probe) mais bloqué par 13 expressions non parsables (arrows/typeof/new/
# ?.) + 24 `x-html` réactifs (icônes SVG) → refonte des composants en
# Alpine.data — voir ROADMAP.
CSP_VALUE = (
"default-src 'self'; "
# A20 phase 2 : chart.js/leaflet vendorisés dans /static/js/vendor
# (test_csp_no_cdn_and_vendor), plus aucun hôte CDN tiers.
# `unsafe-eval` reste : Alpine (x-data) + htmx (hx-on/hx-vars) en
# ont besoin → retrait avec le build CSP d'Alpine (reste d'A20).
"script-src 'self' 'unsafe-eval' 'nonce-{nonce}'; "
"script-src-attr 'unsafe-inline'; "
# ponytail: aucun @font-face Google (grep négatif) → les deux
+1 -1
View File
@@ -115,7 +115,7 @@
</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() }}"}'>
<meta name="htmx-config" content='{"inlineScriptNonce": "{{ csp_nonce() }}", "allowEval": false}'>
<script nonce="{{ csp_nonce() }}">
// A38 : helper CSRF unique — défini le plus tôt possible (head) pour
// tous les scripts inline/externes de la page (welcome.html, isolé de
+1 -1
View File
@@ -2,7 +2,7 @@
"openapi": "3.1.0",
"info": {
"title": "FlowDeck",
"version": "7.36.0"
"version": "7.37.0"
},
"paths": {
"/auth/register": {
+14
View File
@@ -142,6 +142,20 @@ test('gate A20 : palette Ctrl+K (Alpine + recherche GET)', async ({ page }) => {
);
expect(items).toBeGreaterThan(0);
// Alpine doit être lié (build CSP : le x-data + ses expressions évalués
// SANS eval) sur un composant réel de la page
const alpine = await page.evaluate(() => {
const el = document.querySelector('[x-data]');
if (!el || !window.Alpine) return 'absent';
try {
const data = window.Alpine.$data(el);
return data && typeof data === 'object' ? 'ok:' + Object.keys(data).slice(0, 3).join(',') : 'vide';
} catch (e) {
return 'throw:' + e.message;
}
});
expect(alpine).toMatch(/^ok:/);
// Échap ferme la palette
await page.keyboard.press('Escape');
await page.waitForTimeout(300);
+2 -2
View File
@@ -8,8 +8,8 @@
═══════════════════════════════════════════════════════════ */
'use strict';
const CACHE_NAME = 'flowdeck-v6';
const DATA_CACHE = 'flowdeck-data-v6';
const CACHE_NAME = 'flowdeck-v7'; // v7 : A20 (htmx allowEval off, Inter auto-hébergé)
const DATA_CACHE = 'flowdeck-data-v7';
// App shell (assets versionnés comme référencés dans les templates).
const PRECACHE_URLS = [
+2 -1
View File
@@ -226,7 +226,8 @@ def _assert_nonce(csp: str, html: str) -> str:
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 "'unsafe-eval'" in script_src # reste A20 phase 3 (Alpine standard,
# build CSP bloqué : 13 exprs non parsables + 24 x-html réactifs → ROADMAP)
assert "script-src-attr 'unsafe-inline'" in csp
tags = [
mm.group(0)