fix: A19 terminé — plus aucun préfixe cookie-auth exempt du CSRF (v7.3.6)
FlowDeck CI / test (push) Successful in 20m10s
FlowDeck CI / lint (push) Successful in 1m51s
FlowDeck CI / docker (push) Canceled after 0s

- 46 appels non-GET équipés de `X-CSRF-Token` (expression cookie en ligne,
  portée indifférente) : agent_panel (9), settings (12), local_workspace (15),
  gitea_workspace (4), workspace (2), workspaces (5), library (2), welcome (2 en v7.3.5)
- 5 derniers préfixes sortis d'`EXCLUDED_PATHS` : /api/workspace (+/api/workspaces),
  /api/local-workspace, /api/settings, /api/gitea, /api/agent
- il ne reste exempté que : Bearer (/api/webhook, /api/v1, /api/v2, /scim/v2),
  callbacks /auth/*, pages publiques (/s/, /f/), /api/csrf-token et /api/frontend-error
- vérif : `node --check` des <script> des 39 templates → 0 échec (avant et après)
- tests : `anon_csrf()` sur les 5 tests d'anonymat devenus CSRF-first
- suite **1026/1026** · `ruff check app tests` OK
This commit is contained in:
2026-10-01 07:41:23 -04:00
parent cf76e00f12
commit 1f705ce512
16 changed files with 87 additions and 69 deletions
+16
View File
@@ -1,5 +1,21 @@
# Changelog - FlowDeck
## v7.3.6 (2026-09-30) — Audit sécurité : A19 terminé (aucun préfixe cookie-auth exempt)
### Fixed
- **A19 (fin)** — les 46 appels non-GET restants des 5 derniers préfixes
(`/api/agent`, `/api/settings`, `/api/local-workspace`, `/api/gitea`,
`/api/workspace` + `/api/workspaces`) reçoivent `X-CSRF-Token` (expression
cookie en ligne, portée indifférente fonction/Alpine/attribut) ; les 5
préfixes sortent d'`EXCLUDED_PATHS`
- Vérification syntaxe : les `<script>` des 39 templates passent `node --check`
(interpolations Jinja neutralisées) — 0 échec avant/après
- Tests : `anon_csrf()` là où le 403 CSRF masquait le 401 attendu, paire
CSRF sur le TestClient jetable de `test_sessions_listed_and_revocable`
- suite **1026/1026** · `ruff check app tests` OK — la liste CSRF ne contient
plus que du Bearer, des callbacks `/auth/*`, des pages publiques et de l'infra
## v7.3.5 (2026-09-30) — Audit sécurité : A19 (partiel) — CSRF réduit aux vrais cas
### Fixed
+2 -2
View File
@@ -1140,7 +1140,7 @@ Quality DB views, Agent IA Palette → Realtime + E
- [x] **A16 — Lectures de pages/export sans aucune ACL** : `export.py:53` (`_load_page_or_404` = simple `SELECT ... WHERE id=?`), `dashboard.py:1141-1186` (`download_page_file`, `page_file_content`), et la lecture legacy `board.py:1420-1424` → contenu de **toute** page énumérable par id, sans session. *Fix : passer par `PermissionManager.can_view_page` + 401 anonymous. Effort : **M**.*
- [x] **A17 — Router legacy `/api` qui mute sans auth** : `move_card` (`api.py:98`), `set_col_mapping` (177), `delete_col_mapping` (207), `create_issue`/`update_issue` (281/322), `delete_checklist[_item]` (522/531), `PUT /users/me` (558) → seul garde = `_check_rate_limit`. *Fix : un `dependencies=[Depends(...)]` au niveau du router (session **ou** Bearer). Effort : **S**.*
- [x] **A18 — Collection publiée quelconque + stocké XSS** : `GET /workspace/public/{collection_id}` (`workspace.py:699-719`) « no auth required », **ignore les flags `restricted/private`**, et interpole `coll['name']`/`p['title']` dans un `HTMLResponse(f"""…""")` sans `html.escape`. *Fix : respecter les flags de partage + `html.escape`. Effort : **S**.*
- [ ] **A19 — Liste CSRF trop large (34 préfixes, match `startswith`) — PARTIEL 2026-09-30 : 12 préfixes retirés après scan du front** (`/db/`, `/workspace`, `/workspace/automations`, `/board/api/favorites`, `/api/pages`, `/api/recents`, `/api/notifications`, `/api/comments`, `/api/automations`, `/api/github`, `/api/admin`, `/api/onboarding` — les 2 call sites `welcome.html` équipés de `X-CSRF-Token`) ; reste : `/api/workspace`(+`/api/workspaces`), `/api/local-workspace`, `/api/settings`, `/api/gitea`, `/api/agent` = 49 fetch sans header à équiper (helper `adminFetch`/`csrfFetch` déjà en place) : `csrf.py:21,25` couvre `/api/v2`, `/api/admin`, `/db/`, `/workspace`, `/api/user`, `/api/settings`, `/board/api/pages`, `/api/local-workspace`, `/api/comments`, `/api/agent`, `/api/automations`, `/auth/2fa` — tous **cookie-auth**. Seul `/scim/v2` est justifié par le commentaire de la ligne 19-20. Bonus : `/api/workspace` exempt aussi `/api/workspaces/*`. Filet restant = `SameSite=Lax` par défaut (jamais déclaré explicitement dans `main.py:150`). *Fix : garder un petit ensemble SAFE (webhooks, `/api/v1`, `/api/v2` Bearer, `/scim/v2`, callbacks OAuth/SSO) + ancrer les préfixes ; ajouter le header sur les 49 `fetch()` concernés (helper `csrfFetch` existe déjà : `base.html:892`). Effort : **M**.*
- [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**.*
- [ ] **A21 — `sqlite3` synchrone sur l'event loop** : `get_conn()` (`db.py:833-843`) est synchrone et **510 des 689 `async def` de routes** l'appellent (805 occurrences au total ; 0 `run_in_threadpool`, 1 seul `asyncio.to_thread` dans tout le dépôt : `semantic_search.py:262`) ; connexion neuve par requête (`connect` + 2 PRAGMA), **aucun `busy_timeout`**. Chaque requête bloque la boucle. *Fix : wrapper async (`anyio.to_thread.run_sync`) partagé, migrer d'abord `api_v2`/`dashboard`/`collections`/`board` + `PRAGMA busy_timeout=5000`. Effort : **M**.*
- [x] **A22 — Validateur d'upload = code mort** : `validate_upload()` + `ALLOWED_EXTENSIONS` + `MAX_UPLOAD_SIZE` (`security.py:33-40`) n'ont **aucun appelant** ; `upload_local_workspace_file` (`dashboard.py:1507-1574`) fait `file_path.write_bytes(content)` après seulement `Path(filename).name` — ni taille, ni extension, ni auth, et `/api/local-workspace` est exclu du CSRF. Chemin aussi codé en dur `Path(f"/data/uploads/...")` au lieu de `FLOWDECK_DATA_DIR`. *Fix : appeler `validate_upload()` avant `read()` + session. Effort : **S**.*
@@ -1185,4 +1185,4 @@ Quality DB views, Agent IA Palette → Realtime + E
→ Puis **A3–A8** (le bloc « fallback admin ») d'un seul tenant, puis **A10** (autoescape) qui débloque A18/A20.
*Audit produit le 2026-09-30 · 43 items · aucun code modifié ( ROADMAP seul ).*
→ **A1–A9 corrigés le 2026-09-30** : deps réinstallées (`pyotp`/`webauthn`/`cbor2`), rebinding de `settings` supprimé dans `test_v54.py` → **suite 1016/1016 verts**, cycle committé (`1706ad1`) + tag `v7.3.0` poussé, `.db`/fichiers de test désindexés, `APP_SECRET_KEY` roté dans `.env` (sessions révoquées) · **A3–A8 : 401 sans session sur les routes de compte (mdp actuel exigé), tokens `/api/v1` + `/api/user` sans session → 401, CRUD membres d'espace sous session+role admin, `_require_view`/`_require_edit` sans session → 404/401, création/lecture de page sous session, `/board/api/pages` + `/api/user` sortis du CSRF exempt, seed admin sans mdp en dur (aléatoire ou `FLOWDECK_ADMIN_PASSWORD`). Tests : client connecte par defaut (`_TestSessionAuth`), helper `anon()` sur les 40 tests d'anonymat → suite 1016/1016 + ruff OK, commit `d125eb3` · **A10 : `app/templating.py` (ENV partagé + autoescape `select_autoescape(["html"])`) remplace les 29 instantiations, `|safe` retriés (corps d'issue/commentaires echappes, `sidebar_config` en `|tojson`) → suite 1016/1016, version 7.3.1 · **A11 (traversal avatar) + A18 (vue publique : 404 restricted/private + html.escape)** : `tests/test_audit_p0_fixes.py`, suite 1019/1019, version 7.3.2 · **A12/A13/A15/A17/A22/A23/A24** : garde SSRF par hop, routers automations + /api sous session/Bearer, webhooks admin+URL publique, uploads validés, N+1 en GROUP BY/executemany, 2 doublons de routes supprimés → suite 1025/1025, version 7.3.3 · **A16** : exports + pièces jointes sous session + `can_view_page` → suite 1026/1026, version 7.3.4 · **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.**
→ **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.**
+1 -1
View File
@@ -1 +1 @@
7.3.5
7.3.6
+1 -1
View File
@@ -1,6 +1,6 @@
# WORKLOAD — FlowDeck Notion Clone
> **Début**: 2026-07-08 | **Version**: v7.3.5 (audit sécurité — A19 partiel) | **Statut**: EN COURS 🔄
> **Début**: 2026-07-08 | **Version**: v7.3.6 (audit sécurité — A19 terminé) | **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
@@ -153,7 +153,7 @@ async def lifespan(_app: FastAPI):
app = FastAPI(
title="FlowDeck",
version="7.3.5",
version="7.3.6",
docs_url="/docs",
redoc_url="/redoc",
lifespan=lifespan,
+10 -11
View File
@@ -18,21 +18,20 @@ class CSRFMiddleware(BaseHTTPMiddleware):
SAFE_METHODS = {"GET", "HEAD", "OPTIONS"}
# NOTE: ``/scim/v2`` is Bearer-token only (no cookie auth), so CSRF does not
# apply — IdP SCIM clients (Okta, Entra) cannot send an X-CSRF-Token.
# A19 : le CSRF ne s'exempte plus que pour ce qui ne dépend PAS d'un cookie de
# session (Bearer, webhooks entrants, callbacks OAuth/SSO, pages publiques,
# probe d'infra, remontée d'erreur client).
# Retirés après scan du front (tous les appels non-GET envoient déjà
# `X-CSRF-Token`) : /db/, /workspace, /workspace/automations,
# /board/api/favorites, /api/pages, /api/recents, /api/notifications,
# /api/comments, /api/automations, /api/github, /api/admin, /api/onboarding.
# Reste A19 (front à équiper d'abord) : /api/workspace (couvre aussi
# /api/workspaces), /api/local-workspace, /api/settings, /api/gitea, /api/agent.
# A19 — LISTE FINALE : plus aucun préfixe cookie-auth n'est exempté.
# Tous les appels non-GET du front envoient désormais `X-CSRF-Token`
# (46 sites équipés en v7.3.6 : agent_panel, settings, local_workspace,
# library, gitea_workspace, workspace, workspaces, welcome).
# Ne restent que du machine-to-machine / hors session :
# - Bearer : /api/webhook, /api/v1, /api/v2, /scim/v2
# - callbacks : /auth/* (login, register, SSO, 2FA, WebAuthn)
# - publics : /s/ (sites), /f/ (forms)
# - infra/diag : /api/csrf-token (le jeton lui-même), /api/frontend-error
EXCLUDED_PATHS = {
"/api/webhook", "/api/v1", "/api/v2", "/scim/v2",
"/auth/callback", "/auth/register", "/auth/local-login", "/auth/local-verify",
"/auth/2fa", "/auth/saml", "/auth/oidc", "/auth/webauthn",
"/s/", "/f/", "/api/frontend-error", "/api/csrf-token",
"/api/workspace", "/api/local-workspace", "/api/settings", "/api/gitea", "/api/agent",
"/s/", "/f/", "/api/csrf-token", "/api/frontend-error",
}
async def dispatch(self, request: Request, call_next):
+8 -8
View File
@@ -555,7 +555,7 @@
var payload = {prompt: message};
if(context && context.trim()) payload.context = String(context).slice(0, 20000);
return fetch('/api/agent/generate', {
method:'POST', headers:{'Content-Type':'application/json'},
method:'POST', headers:{'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||'', 'Content-Type':'application/json'},
body: JSON.stringify(payload)
}).then(function(resp){
return resp.json();
@@ -701,7 +701,7 @@
if(self.llmModel) payload.model = self.llmModel;
fetch('/api/agent/generate', {
method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify(payload)
method:'POST', headers:{'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||'', 'Content-Type':'application/json'}, body: JSON.stringify(payload)
}).then(function(r){ return r.json(); }).then(function(d){
self.messages = self.messages.filter(function(m){ return m.id !== thinkId; });
if(d && d.ok && d.text){
@@ -824,7 +824,7 @@
installGallerySkill(slug, icon, name){
var self = this;
fetch('/api/agent/skills/gallery/' + encodeURIComponent(slug) + '/install', {
method: 'POST', headers: {'Content-Type': 'application/json'}, body: '{}'
method: 'POST', headers: {'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||'', 'Content-Type': 'application/json'}, body: '{}'
}).then(function(r){
return r.json().then(function(d){ return {ok: r.ok, d: d}; });
}).then(function(res){
@@ -988,7 +988,7 @@
var body = {title:'Nouvelle conversation'};
if(self.llmProvider) body.provider = self.llmProvider;
if(self.llmModel) body.model = self.llmModel;
fetch('/api/agent/conversations', {method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify(body)})
fetch('/api/agent/conversations', {method:'POST', headers:{'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||'', 'Content-Type':'application/json'}, body: JSON.stringify(body)})
.then(function(r){return r.json()}).then(function(d){
var conv = {id:d.id, title:d.title, updated_at:new Date().toISOString()};
self.conversations.unshift(conv); self.currentConv = conv;
@@ -1005,7 +1005,7 @@
if(self.llmProvider) body.provider = self.llmProvider;
if(self.llmModel) body.model = self.llmModel;
self._ensuring = fetch('/api/agent/conversations', {
method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify(body)
method:'POST', headers:{'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||'', 'Content-Type':'application/json'}, body: JSON.stringify(body)
}).then(function(r){return r.json()}).then(function(d){
var conv = {id:d.id, title:d.title, updated_at:new Date().toISOString()};
self.conversations.unshift(conv); self.currentConv = conv;
@@ -1027,7 +1027,7 @@
if(self.llmProvider) body.provider = self.llmProvider;
if(self.llmModel) body.model = self.llmModel;
fetch('/api/agent/conversations/'+self.currentConv.id, {
method:'PATCH', headers:{'Content-Type':'application/json'}, body: JSON.stringify(body)
method:'PATCH', headers:{'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||'', 'Content-Type':'application/json'}, body: JSON.stringify(body)
}).catch(function(){});
},
@@ -1832,7 +1832,7 @@
if(m.id && /^\d+$/.test(String(m.id))) payload.message_id = Number(m.id);
fetch('/api/agent/feedback', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
headers: {'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||'', 'Content-Type': 'application/json'},
body: JSON.stringify(payload)
}).then(function(r){ return r.json(); }).then(function(d){
if(d && d.status === 'recorded'){ m.fb = rating; }
@@ -1930,7 +1930,7 @@
return fetch('/api/agent/conversations/'+self.currentConv.id+'/run', {
method:'POST',
headers:{'Content-Type':'application/json'},
headers:{'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||'', 'Content-Type':'application/json'},
body: JSON.stringify(payload)
}).then(function(resp){
if(!resp.ok){ return resp.json().then(function(j){ throw new Error(j.detail || ('HTTP '+resp.status)); }); }
+4 -4
View File
@@ -135,7 +135,7 @@ document.addEventListener('alpine:init', () => {
var newPath = oldPath.replace(/[^/]+$/, newName.trim());
fetch('/api/gitea/projects/' + this.owner + '/' + this.repo + '/file', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
headers: {'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||'', 'Content-Type': 'application/json' },
body: JSON.stringify({ path: oldPath, new_path: newPath, message: 'Rename ' + oldPath + ' to ' + newPath })
}).then(function(r){ return r.json(); })
.then(function(d){
@@ -150,7 +150,7 @@ document.addEventListener('alpine:init', () => {
if (!item) return;
if (!confirm('Delete ' + item.name + '?')) return;
var self = this;
fetch('/api/gitea/projects/' + this.owner + '/' + this.repo + '/file?path=' + encodeURIComponent(item.path) + '&sha=' + (item.sha || '') + '&message=Delete ' + item.path, {
fetch('/api/gitea/projects/' + this.owner + '/' + this.repo + '/file?path=' + encodeURIComponent(item.path) + '&sha=' + (item.sha || '') + '&message=Delete ' + item.path, {headers: {'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||''},
method: 'DELETE'
}).then(function(r){
if (r.ok) { self.refreshTree(); }
@@ -245,7 +245,7 @@ document.addEventListener('alpine:init', () => {
if (!path) return;
if (!confirm('Delete ' + path + ' from ' + self.owner + '/' + self.repo + '?')) return;
var sha = el.getAttribute('data-gitea-sha') || '';
fetch('/api/gitea/projects/' + self.owner + '/' + self.repo + '/file?path=' + encodeURIComponent(path) + '&sha=' + encodeURIComponent(sha) + '&message=' + encodeURIComponent('Delete via FlowDeck'), {
fetch('/api/gitea/projects/' + self.owner + '/' + self.repo + '/file?path=' + encodeURIComponent(path) + '&sha=' + encodeURIComponent(sha) + '&message=' + encodeURIComponent('Delete via FlowDeck'), {headers: {'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||''},
method: 'DELETE',
}).then(function(r) {
if (r.ok) self.refreshTree();
@@ -387,7 +387,7 @@ document.addEventListener('alpine:init', () => {
if (!this.filePath) return;
if (!confirm('Delete ' + this.filePath + ' from ' + this.owner + '/' + this.repo + '?')) return;
try {
var r = await fetch('/api/gitea/projects/' + this.owner + '/' + this.repo + '/file?path=' + encodeURIComponent(this.filePath) + '&sha=' + encodeURIComponent(this.fileSha) + '&message=' + encodeURIComponent('Delete via FlowDeck'), {
var r = await fetch('/api/gitea/projects/' + this.owner + '/' + this.repo + '/file?path=' + encodeURIComponent(this.filePath) + '&sha=' + encodeURIComponent(this.fileSha) + '&message=' + encodeURIComponent('Delete via FlowDeck'), {headers: {'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||''},
method: 'DELETE',
});
if (r.ok) {
+2 -2
View File
@@ -1198,7 +1198,7 @@ function libraryPage() {
var item = store && store.node;
if (!item) return;
var self = this;
fetch('/api/local-workspace/items/' + item.id + '/tags/' + tagId, { method: 'DELETE' })
fetch('/api/local-workspace/items/' + item.id + '/tags/' + tagId, {headers: {'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||''}, method: 'DELETE' })
.then(function(r) {
if (!r.ok) return;
item.tags = (item.tags || []).filter(function(t) { return t.id !== tagId; });
@@ -1218,7 +1218,7 @@ function libraryPage() {
try {
var r = await fetch('/api/local-workspace/items/' + item.id + '/tags', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
headers: {'Content-Type': 'application/json', 'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||''},
body: JSON.stringify({name: tagName, color: color})
});
if (r.ok) {
+15 -15
View File
@@ -781,7 +781,7 @@ window._wsInitData = (function() {
var self = this;
// Soft-delete all selected items
for (var i=0; i<ids.length; i++) {
await fetch('/api/local-workspace/items/' + ids[i], { method: 'DELETE' });
await fetch('/api/local-workspace/items/' + ids[i], {headers: {'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||''}, method: 'DELETE' });
}
this.clearSelection();
this._reloadAfterAction();
@@ -1207,7 +1207,7 @@ window._wsInitData = (function() {
color = color || (store && store.newTagColor) || '#787774';
try {
var r = await fetch('/api/local-workspace/items/' + node.id + '/tags', {
method: 'POST', headers: {'Content-Type':'application/json'},
method: 'POST', headers: {'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||'', 'Content-Type':'application/json'},
body: JSON.stringify({name: tagName, color: color})
});
if (r.ok) {
@@ -1344,7 +1344,7 @@ window._wsInitData = (function() {
if (!newName) return;
try {
var r = await fetch('/api/local-workspace/items/' + id, {
method: 'PUT', headers: {'Content-Type':'application/json'},
method: 'PUT', headers: {'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||'', 'Content-Type':'application/json'},
body: JSON.stringify({name: newName})
});
if (r.ok) {
@@ -1524,7 +1524,7 @@ window._wsInitData = (function() {
this.renamingId = null;
if (!n || n === node.name) return;
var r = await fetch('/api/local-workspace/items/' + node.id, {
method: 'PUT', headers: {'Content-Type':'application/json'},
method: 'PUT', headers: {'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||'', 'Content-Type':'application/json'},
body: JSON.stringify({name: n})
});
if (r.ok) {
@@ -1579,7 +1579,7 @@ window._wsInitData = (function() {
var targetId = this.currentFolder > 0 ? this.currentFolder : null;
this.clipboard.forEach(function(id) {
fetch('/api/local-workspace/items/' + id + '/move', {
method: 'PUT', headers: {'Content-Type':'application/json'},
method: 'PUT', headers: {'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||'', 'Content-Type':'application/json'},
body: JSON.stringify({parent_id: targetId})
}).then(function(r) { if (r.ok) self._reloadAfterAction(); });
});
@@ -1590,7 +1590,7 @@ window._wsInitData = (function() {
// ── Duplicate ──
async duplicateItem(node) {
var r = await fetch('/api/local-workspace/items', {
method: 'POST', headers: {'Content-Type':'application/json'},
method: 'POST', headers: {'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||'', 'Content-Type':'application/json'},
body: JSON.stringify({name: node.name + ' (copy)', type: node.type || 'page',
parent_id: this.currentFolder > 0 ? this.currentFolder : null})
});
@@ -1618,7 +1618,7 @@ window._wsInitData = (function() {
this.undoMessage = 'Deleted "' + (node.name || 'item') + '"';
this.undoVisible = true;
// Delete via API
var r = await fetch('/api/local-workspace/items/' + this.undoItemId, { method:'DELETE' });
var r = await fetch('/api/local-workspace/items/' + this.undoItemId, {headers: {'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||''}, method:'DELETE' });
if (!r.ok) { this.undoVisible = false; return; }
// Reload from API for reliability (sidebar refresh handled by _reloadAfterAction)
this._reloadAfterAction();
@@ -1643,7 +1643,7 @@ window._wsInitData = (function() {
self._reloadAfterAction();
return;
}
fetch('/api/local-workspace/items/' + ids[i] + '/restore', { method: 'POST' })
fetch('/api/local-workspace/items/' + ids[i] + '/restore', {headers: {'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||''}, method: 'POST' })
.then(function(r) { if (r.ok) restored++; })
.finally(function() { restoreOne(i + 1); });
}
@@ -1941,7 +1941,7 @@ window._wsInitData = (function() {
if (this.parentFolder) body.parent_id = this.parentFolder.db_id;
var r = await fetch('/api/local-workspace/items', {
method:'POST',
headers:{'Content-Type':'application/json'},
headers:{'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||'', 'Content-Type':'application/json'},
body:JSON.stringify(body)
});
if (r.ok) {
@@ -2025,7 +2025,7 @@ window._wsInitData = (function() {
if (!n||!this.target) return;
var r = await fetch('/api/local-workspace/items/'+this.target.db_id, {
method:'PUT',
headers:{'Content-Type':'application/json'},
headers:{'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||'', 'Content-Type':'application/json'},
body:JSON.stringify({name:n})
});
if (r.ok) { this._reloadAfterAction(); }
@@ -2038,7 +2038,7 @@ window._wsInitData = (function() {
async doDelete() {
if (!this.target) return;
var r = await fetch('/api/local-workspace/items/'+this.target.db_id, { method:'DELETE' });
var r = await fetch('/api/local-workspace/items/'+this.target.db_id, {headers: {'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||''}, method:'DELETE' });
if (r.ok) { this._reloadAfterAction(); }
},
@@ -2231,7 +2231,7 @@ window._wsInitData = (function() {
try {
var r = await fetch('/api/local-workspace/items/' + itemId + '/move', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
headers: {'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||'', 'Content-Type': 'application/json' },
body: JSON.stringify(body)
});
if (r.ok) {
@@ -2327,7 +2327,7 @@ window._wsInitData = (function() {
try {
var r = await fetch('/api/local-workspace/items/' + itemId + '/tags', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
headers: {'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||'', 'Content-Type': 'application/json'},
body: JSON.stringify({name: tagName})
});
if (r.ok) {
@@ -2346,7 +2346,7 @@ window._wsInitData = (function() {
async removeTag(itemId, tagId) {
try {
var r = await fetch('/api/local-workspace/items/' + itemId + '/tags/' + tagId, {
var r = await fetch('/api/local-workspace/items/' + itemId + '/tags/' + tagId, {headers: {'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||''},
method: 'DELETE'
});
if (r.ok) {
@@ -2492,7 +2492,7 @@ window._wsInitData = (function() {
try {
await fetch('/api/local-workspace/items/' + ids[i] + '/move', {
method: 'PUT',
headers: {'Content-Type':'application/json'},
headers: {'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||'', 'Content-Type':'application/json'},
body: JSON.stringify({ parent_id: parentId || null })
});
} catch(e) {}
+12 -12
View File
@@ -1337,7 +1337,7 @@ function settingsInit() {
var n = this.newTagName.trim();
if (!n) return;
var r = await fetch('/api/settings/tags', {
method: 'POST', headers: {'Content-Type':'application/json'},
method: 'POST', headers: {'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||'', 'Content-Type':'application/json'},
body: JSON.stringify({name: n, color: this.newTagColor})
});
if (r.ok) { this.newTagName = ''; this.newTagColor = '#787774'; await this.loadTags(); }
@@ -1345,7 +1345,7 @@ function settingsInit() {
async updateTagColor(id, color) {
await fetch('/api/settings/tags/' + id, {
method: 'PUT', headers: {'Content-Type':'application/json'},
method: 'PUT', headers: {'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||'', 'Content-Type':'application/json'},
body: JSON.stringify({color: color})
});
await this.loadTags();
@@ -1353,7 +1353,7 @@ function settingsInit() {
async deleteTag(id) {
if (!confirm('Delete this tag?')) return;
await fetch('/api/settings/tags/' + id, { method: 'DELETE' });
await fetch('/api/settings/tags/' + id, {headers: {'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||''}, method: 'DELETE' });
await this.loadTags();
},
@@ -1375,7 +1375,7 @@ function settingsInit() {
this.renamingTag = null; return;
}
await fetch('/api/settings/tags/' + tag.id, {
method: 'PUT', headers: {'Content-Type':'application/json'},
method: 'PUT', headers: {'Content-Type':'application/json', 'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||''},
body: JSON.stringify({name: newName})
});
this.renamingTag = null;
@@ -1648,7 +1648,7 @@ function settingsInit() {
try {
var r = await fetch('/api/agent/keys/' + id + '/models', {
method: 'POST',
headers: {'Content-Type':'application/json'},
headers: {'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||'', 'Content-Type':'application/json'},
body: JSON.stringify({api_key: f.api_key, api_base: f.api_base})
});
var d = await r.json();
@@ -1677,7 +1677,7 @@ function settingsInit() {
if (f.models && f.models.length) body.models = f.models;
var r = await fetch('/api/agent/keys/' + id, {
method: 'PUT',
headers: {'Content-Type':'application/json'},
headers: {'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||'', 'Content-Type':'application/json'},
body: JSON.stringify(body)
});
var d = await r.json();
@@ -1713,7 +1713,7 @@ function settingsInit() {
if (f.api_key) body.api_key = f.api_key;
var r = await fetch('/api/agent/keys/' + id + '/test', {
method: 'POST',
headers: {'Content-Type':'application/json'},
headers: {'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||'', 'Content-Type':'application/json'},
body: JSON.stringify(body)
});
var d = await r.json();
@@ -1739,7 +1739,7 @@ function settingsInit() {
var f = this.keyForm(id);
f.deleting = true; f.msg = ''; f.ok = false;
try {
var r = await fetch('/api/agent/keys/' + id, { method: 'DELETE' });
var r = await fetch('/api/agent/keys/' + id, {headers: {'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||''}, method: 'DELETE' });
var d = await r.json();
if (r.ok) {
f.has_key = false; f.api_key = ''; f.models = []; f.model = ''; f.last_error = '';
@@ -1899,7 +1899,7 @@ function settingsInit() {
if (!file) return;
var form = new FormData();
form.append('file', file);
var r = await fetch('/api/settings/avatar', { method: 'POST', body: form });
var r = await fetch('/api/settings/avatar', {headers: {'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||''}, method: 'POST', body: form });
if (r.ok) {
var d = await r.json();
this.avatarUrl = d.avatar_url + '?t=' + Date.now();
@@ -1910,7 +1910,7 @@ function settingsInit() {
async selectAvatarColor(color) {
this.avatarColor = color;
var r = await fetch('/api/settings/avatar-color', {
method: 'POST', headers: {'Content-Type':'application/json'},
method: 'POST', headers: {'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||'', 'Content-Type':'application/json'},
body: JSON.stringify({color: color})
});
if (r.ok) { this.avatarUrl = ''; }
@@ -2055,7 +2055,7 @@ function settingsInit() {
// ── v5.2.0 API tokens ──
async loadApiTokens() {
try {
var r = await fetch('/api/settings/tokens', {credentials:'same-origin'});
var r = await fetch('/api/settings/tokens', {headers: {'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||''}, credentials:'same-origin'});
var d = await r.json();
this.apiTokens = d.tokens || [];
} catch(e) { this.apiTokens = []; }
@@ -2065,7 +2065,7 @@ function settingsInit() {
if (!name) return;
try {
var r = await fetch('/api/settings/tokens', {method:'POST', credentials:'same-origin',
headers: {'Content-Type':'application/json'}, body: JSON.stringify({name: name})});
headers: {'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||'', 'Content-Type':'application/json'}, body: JSON.stringify({name: name})});
var d = await r.json();
if (!r.ok) { window.showToast && window.showToast(d.detail || 'Erreur', 'error'); return; }
this.newToken = d;
+1 -1
View File
@@ -174,7 +174,7 @@ function workspacePage() {
if (!this.newProjectName.trim()) return;
const r = await fetch('/api/workspace/projects', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
headers: {'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||'', 'Content-Type': 'application/json'},
body: JSON.stringify({name: this.newProjectName.trim()})
});
if (r.ok) {
+4 -4
View File
@@ -214,7 +214,7 @@ function workspacesPage() {
},
async selectLocal(ws) {
await fetch(`/api/workspaces/${ws.id}/select`, {method:'POST'});
await fetch(`/api/workspaces/${ws.id}/select`, {headers: {'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||''}, method:'POST'});
window.location = '/local-workspace';
},
@@ -222,7 +222,7 @@ function workspacesPage() {
if (!this.wsName.trim()) return;
await fetch('/api/workspaces', {
method:'POST',
headers:{'Content-Type':'application/json'},
headers:{'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||'', 'Content-Type':'application/json'},
body:JSON.stringify({name:this.wsName.trim()})
});
this.wsName = '';
@@ -240,7 +240,7 @@ function workspacesPage() {
if (!this.wsName.trim()||!this.renameTarget) return;
await fetch(`/api/workspaces/${this.renameTarget.id}`, {
method:'PUT',
headers:{'Content-Type':'application/json'},
headers:{'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||'', 'Content-Type':'application/json'},
body:JSON.stringify({name:this.wsName.trim()})
});
this.wsName = '';
@@ -251,7 +251,7 @@ function workspacesPage() {
async deleteWs(ws) {
if (!confirm(`Delete workspace "${ws.name}" and all its pages?`)) return;
await fetch(`/api/workspaces/${ws.id}`, {method:'DELETE'});
await fetch(`/api/workspaces/${ws.id}`, {headers: {'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||''}, method:'DELETE'});
await this.load();
},
+3 -3
View File
@@ -4,7 +4,7 @@ import os
import tempfile
import pytest
from conftest import anon, login_test_client
from conftest import anon, anon_csrf, login_test_client
from fastapi.testclient import TestClient
@@ -1670,7 +1670,7 @@ def test_gitea_status_with_expired_token(client):
def test_gitea_disconnect_no_auth(client):
anon(client)
anon_csrf(client)
"""DELETE /api/gitea/disconnect — 401 without session."""
resp = client.delete("/api/gitea/disconnect")
assert resp.status_code == 401
@@ -1952,7 +1952,7 @@ def test_gitea_private_pages_list_no_auth(client):
def test_gitea_private_pages_create_no_auth(client):
anon(client)
anon_csrf(client)
"""POST private-pages — 401 without session."""
resp = client.post("/api/gitea/projects/owner/repo/private-pages", json={"title": "Test"})
assert resp.status_code == 401
+1 -1
View File
@@ -96,7 +96,7 @@ def test_upload_requires_session_and_validates_files(client):
assert validate_upload("virus.exe", 10) is not None
assert validate_upload("big.txt", 11 * 1024 * 1024) is not None
anon(client)
anon_csrf(client)
r = client.post("/api/local-workspace/upload", files={"files": ("a.txt", b"x", "text/plain")})
assert r.status_code == 401
+6 -3
View File
@@ -8,7 +8,7 @@ import asyncio
import os
import pytest
from conftest import anon
from conftest import anon_csrf
from fastapi import HTTPException
from fastapi.testclient import TestClient
@@ -67,7 +67,7 @@ def test_api_token_lifecycle(client):
def test_api_tokens_require_authentication(client):
anon(client)
anon_csrf(client)
r1 = client.get("/api/settings/tokens")
assert r1.status_code == 401
r2 = client.post("/api/settings/tokens", json={"name": "x"})
@@ -106,6 +106,9 @@ def test_sessions_listed_and_revocable(client):
# Create a fresh client with alice's cookie to revoke.
client_alice = TestClient(client.app)
client_alice.cookies.set("flowdeck_session", alice_cookie)
# CSRF aussi sur ce client jetable (la route n'est plus exemptée, A19).
client_alice.cookies.set("csrf_token", "csrf-alice")
client_alice.headers["X-CSRF-Token"] = "csrf-alice"
revoke = client_alice.post(f"/api/settings/sessions/{alice_sid}/revoke")
assert revoke.status_code == 200
@@ -198,7 +201,7 @@ def test_backup_disabled_returns_none(client):
def test_backup_admin_api(client):
anon(client)
anon_csrf(client)
"""The backup admin API is admin-only and snapshots on demand."""
# Unauthenticated → forbidden.
assert client.post("/api/settings/backups/run").status_code == 403