fix: A43 TERMINÉ + A38 phase 1 — CSRF rendu côté serveur, helper unique (v7.33.0)
FlowDeck CI / lint (push) Successful in 1m57s
FlowDeck CI / test (push) Successful in 15m20s
FlowDeck CI / docker (push) Canceled after 0s

A43 (les 4 items sont clos) :
- `__CSRF_PLACEHOLDER__` supprimé : ContextVar CSRF_TOKEN posée par
  CSRFMiddleware avant call_next (même mécanique que le nonce CSP),
  global `{{ csrf_token() }}` dans templating, base.html rend
  `{"X-CSRF-Token":{{ csrf_token()|tojson }}}` — vide si cookie absent,
  `htmx:configRequest` re-lit le cookie à chaque appel → plus jamais de
  jeton factice servi.
- Palette : `fetch('/api/search…')` SANS header (GET ∈ SAFE_METHODS →
  CSRF inapplicable) — le JSON.parse du body-attr par frappe disparaît.
- utcnow déprécié = 0 dans app/**.py ; health loggé (A25) + booléens db/
  gitea = raison ; probe réseau = voulu (test de connectivité).

A38 phase 1 (CSRF unifié) :
- `window.getCsrf()` unique dans le <head> de base.html.
- 76 lectures brutes du cookie → getCsrf() dans 13 fichiers (47 formes
  `(…||[])[1]||''`, 25 déclarations `const X = match(…)` avec conversion
  de leurs usages `X?X[1]:''` → `X` (noms collectés PAR FICHIER, jamais
  de règle globale), 4 formes espacées).
- Définitions dupliquées supprimées : card_detail.html (multi-lignes),
  database_table.js (1 ligne dans IIFE → chute sur le global).
- Les 3 variantes de base.html (IIFE getCsrf + 2 getCsrfToken) →
  `return getCsrf()`.
- welcome.html garde sa lecture locale (page autonome sans base) ;
  reste exactement 1 raw dans base.html (la déf head) + 2 dans welcome.

Test : test_csrf_server_rendered_no_placeholder (pas de placeholder +
token du hx-headers == cookie csrf_token).

suite **1092/1092** · ruff OK · node --check vert · docs à jour
This commit is contained in:
2026-10-02 08:45:27 -04:00
parent 0bc74ad728
commit 770fdc2b68
25 changed files with 190 additions and 119 deletions
+1 -1
View File
@@ -185,7 +185,7 @@ async def lifespan(_app: FastAPI):
app = FastAPI(
title="FlowDeck",
version="7.32.0",
version="7.33.0",
docs_url="/docs",
redoc_url="/redoc",
lifespan=lifespan,
+5
View File
@@ -7,6 +7,8 @@ from starlette.middleware.base import BaseHTTPMiddleware
from starlette.requests import Request
from starlette.responses import JSONResponse
from app.templating import CSRF_TOKEN
class CSRFMiddleware(BaseHTTPMiddleware):
"""Lightweight CSRF protection for state-changing requests.
@@ -35,6 +37,9 @@ class CSRFMiddleware(BaseHTTPMiddleware):
}
async def dispatch(self, request: Request, call_next):
# A43 : jeton expose aux templates (base.html hx-headers) — posé AVANT
# call_next, comme le nonce CSP (meme mecanisme ContextVar).
CSRF_TOKEN.set(request.cookies.get("csrf_token", ""))
# Webhook receiver, OAuth callback, and internal API are exempt
if any(request.url.path.startswith(p) for p in self.EXCLUDED_PATHS):
return await call_next(request)
+1 -1
View File
@@ -88,7 +88,7 @@ document.addEventListener('alpine:init', function () {
return Math.floor(diff / 86400) + 'd ago';
},
csrf() {
return (document.cookie.match(/csrf_token=([^;]+)/) || [])[1] || '';
return getCsrf();
},
refreshCount() {
var self = this;
+2 -2
View File
@@ -117,8 +117,8 @@
} catch(e) {}
},
saveProfile() {
const csrf = document.cookie.match(/csrf_token=([^;]+)/);
const token = csrf ? csrf[1] : '';
const csrf = getCsrf();;
const token = csrf;
fetch(`/api/users/me?full_name=${encodeURIComponent(this.profile.full_name)}&email=${encodeURIComponent(this.profile.email)}`, {
method: 'PUT', headers: { 'X-CSRF-Token': token }
}).then(() => {
+13 -10
View File
@@ -116,11 +116,20 @@
{# 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 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
// base, garde sa propre lecture du cookie).
window.getCsrf = function() {
var m = document.cookie.match(/csrf_token=([^;]+)/);
return m ? m[1] : '';
};
</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/sortable.min.js" defer data-cfasync="false"></script>
</head>
<body hx-headers='{"X-CSRF-Token":"__CSRF_PLACEHOLDER__"}'{% if embed_mode %} class="embed-mode"{% endif %}>
<body hx-headers='{"X-CSRF-Token":{{ csrf_token() | tojson }}}'{% if embed_mode %} class="embed-mode"{% endif %}>
<div class="app-layout" x-data="appState()">
<!-- ═══════════ MOBILE SIDEBAR OVERLAY ═══════════ -->
@@ -796,10 +805,6 @@
<script data-cfasync="false" nonce="{{ csp_nonce() }}">
// Inject CSRF token into HTMX headers
(function() {
const getCsrf = () => {
const m = document.cookie.match(/csrf_token=([^;]+)/);
return m ? m[1] : '';
};
document.body.setAttribute('hx-headers', JSON.stringify({'X-CSRF-Token': getCsrf()}));
document.addEventListener('htmx:configRequest', function(evt) {
evt.detail.headers['X-CSRF-Token'] = getCsrf();
@@ -853,8 +858,7 @@
// these functions for consistent behaviour.
window.FlowDeck = {
getCsrfToken: function() {
var m = document.cookie.match(/csrf_token=([^;]+)/);
return m ? m[1] : '';
return getCsrf();
},
/** Reflète le rename d'une page/dossier dans toute l'UI : sidebar gauche
@@ -1733,8 +1737,7 @@
addPage(section) { this.newPage(section); },
addSubPage(id) { this.newSubPage(id); },
getCsrfToken() {
const m = document.cookie.match(/csrf_token=([^;]+)/);
return m ? m[1] : '';
return getCsrf();
},
newPage(section) {
const project = this.workspaceKey || '';
@@ -2292,7 +2295,7 @@
return;
}
state.actions = false;
fetch('/api/search?q='+encodeURIComponent(q), {headers:{'X-CSRF-Token': document.body.getAttribute('hx-headers') ? (JSON.parse(document.body.getAttribute('hx-headers'))['X-CSRF-Token']||'') : ''}})
fetch('/api/search?q='+encodeURIComponent(q))
.then(function(r){ return r.json(); })
.then(function(data){
if(input.value.trim()!==q) return; // stale
-6
View File
@@ -100,12 +100,6 @@
</div>
<script nonce="{{ csp_nonce() }}">
// ponytail: CSRF helper
function getCsrf() {
const m = document.cookie.match(/csrf_token=([^;]+)/);
return m ? m[1] : '';
}
function cardDetail() {
return {
updateField(field, value) {
+4 -4
View File
@@ -68,8 +68,8 @@
search: '',
items: [],
async init() {
const csrf = document.cookie.match(/csrf_token=([^;]+)/);
const token = csrf ? csrf[1] : '';
const csrf = getCsrf();;
const token = csrf;
try {
const r = await fetch('/board/api/trash', { headers: { 'X-CSRF-Token': token } });
this.items = await r.json();
@@ -80,13 +80,13 @@
return this.items.filter(i => !q || i.name.toLowerCase().includes(q) || (i.path||'').toLowerCase().includes(q));
},
async restore(id) {
const csrf = document.cookie.match(/csrf_token=([^;]+)/);
const csrf = getCsrf();;
const r = await fetch(`/board/api/trash/${id}/restore`, { method: 'POST', headers: { 'X-CSRF-Token': csrf?.[1] || '' } });
if (r.ok) { this.items = this.items.filter(i => i.id !== id); }
},
async deleteForever(id) {
if (!confirm('Permanently delete this page? This cannot be undone.')) return;
const csrf = document.cookie.match(/csrf_token=([^;]+)/);
const csrf = getCsrf();;
const r = await fetch(`/board/api/trash/${id}`, { method: 'DELETE', headers: { 'X-CSRF-Token': csrf?.[1] || '' } });
if (r.ok) { this.items = this.items.filter(i => i.id !== id); }
}
+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: {'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||'', 'Content-Type': 'application/json'},
headers: {'X-CSRF-Token': getCsrf(), 'Content-Type': 'application/json'},
body: JSON.stringify({name: this.newProjectName.trim()})
});
if (r.ok) {
+7
View File
@@ -16,6 +16,12 @@ from jinja2 import Environment, FileSystemLoader, select_autoescape
# dans ce cas, donc rien n'est bloqué).
CSP_NONCE: ContextVar[str] = ContextVar("csp_nonce", default="")
# A43 : jeton CSRF rendu côté serveur dans `hx-headers` (base.html) — posé
# par le middleware CSRF AVANT call_next, lu via `{{ csrf_token() }}`
# (vide = cookie absent sur cette requête, htmx:configRequest re-lit le
# cookie au moment de l'appel → jamais de « __CSRF_PLACEHOLDER__ » servi).
CSRF_TOKEN: ContextVar[str] = ContextVar("csrf_token", default="")
ENV = Environment(
loader=FileSystemLoader("app/templates"),
autoescape=select_autoescape(["html"]),
@@ -33,3 +39,4 @@ except OSError: # pragma: no cover
ENV.globals["asset_version"] = ASSET_VERSION
ENV.globals["csp_nonce"] = lambda: CSP_NONCE.get()
ENV.globals["csrf_token"] = lambda: CSRF_TOKEN.get()