fix: A10 — autoescape Jinja2 activé partout via un Environment partagé
FlowDeck CI / docker (push) Successful in 1m44s
FlowDeck CI / lint (push) Successful in 1m49s
FlowDeck CI / test (push) Successful in 21m54s

- `app/templating.py` : `ENV = Environment(loader=FileSystemLoader("app/templates"), autoescape=select_autoescape(["html"]))`
- les 29 instantiations `Environment(loader=FileSystemLoader("app/templates"))` (9 routers) remplacées par `env = ENV` — plus aucune interpolation `{{ … }}` servie crue, les `|safe` redeviennent efficaces
- re-tri des `|safe` : `card_detail.html` corps d'issue et commentaires échappés (XSS stocké), placeholder de description sorti du ternaire, `sidebar_config` passé en dict + `|tojson` (échappement `</script>` en contexte script)
- `|safe` conservé sur `right_actions` (HTML fabriqué dans les templates, fiable)
- `ruff check app tests` OK · suite **1016/1016 verts**
This commit is contained in:
2026-09-30 22:28:48 -04:00
parent d76d7943fc
commit 69a0aceba6
17 changed files with 117 additions and 74 deletions
+36
View File
@@ -1,5 +1,41 @@
# Changelog - FlowDeck # Changelog - FlowDeck
## v7.3.1 (2026-09-30) — Audit sécurité P0 : A1–A10
> Corrections du bloc critique de l'audit du 2026-09-30 (ROADMAP) : plus aucune
> route cookie-auth n'accepte un anonymous, et Jinja2 échappe enfin sa sortie.
### Fixed
- **A1/A2** — deps `pyotp`/`webauthn`/`cbor2` installées, rebinding de
`app.config.settings` supprimé dans `test_v54.py` (isolation rétablie) ;
cycle v6.8→v7.3 committé + tag `v7.3.0`
- **A3** — `PUT /api/user/password` : 401 sans session + `current_password`
exigé ; helper `_require_user_id()` sur profile/password/token/forge ; `/api/user`
sorti de la liste CSRF exemptée
- **A4** — `POST /api/v1/token` et `POST /api/user/token` : 401 sans session,
chemin legacy `user_id=0` supprimé
- **A5** — CRUD membres d'espace : session + rôle admin de l'espace (ou admin
global), placeholder user créé en `is_admin=0`
- **A6** — `_require_view` → 404 / `_require_edit` → 401 sans session (fin du
legacy single-user sur les collections)
- **A7** — création ET lecture de page → 401 sans session (`PermissionManager`),
`/board/api/pages` sorti du CSRF exempt (+ header manquant côté local workspace)
- **A8** — seed admin sans mot de passe codé en dur : aléatoire au premier boot
(loggé une fois) ou `FLOWDECK_ADMIN_PASSWORD`
- **A9** — `.db`/fichiers de test désindexés + `.gitignore`/`.dockerignore`,
rotation de `APP_SECRET_KEY`
- **A10** — `app/templating.py` : un seul `ENV` avec
`autoescape=select_autoescape(["html"])`, 29 instantiations remplacées ;
re-tri des `|safe` (corps d'issue + commentaires échappés, `sidebar_config`
en `|tojson`)
### Tests
- Client de test connecté par défaut (`_TestSessionAuth` : session + CSRF
injectés hors cookie jar) + helper `anon()` sur les 40 tests d'anonymat
- Suite complète : **1016 passed / 0 failed** · `ruff check app tests` OK
## v7.3.0 (2026-09-29) — Wiki / Teamspaces + Polish (dernière version du cycle v7) ## v7.3.0 (2026-09-29) — Wiki / Teamspaces + Polish (dernière version du cycle v7)
> Connaissance vérifiée et finition collaborative : teamspaces, badge ✅ avec > Connaissance vérifiée et finition collaborative : teamspaces, badge ✅ avec
+2 -2
View File
@@ -1128,7 +1128,7 @@ Quality DB views, Agent IA Palette → Realtime + E
- [x] **A7 — Création de pages sans session, CSRF-exempt** : `POST /board/api/pages` (`board.py:1381-1404`) lit la session mais **ne vérifie jamais `if not user`** (contrairement à `set_page_lock`, `board.py:128-130`), et `/board/api/pages` est exclu du CSRF ; même pattern « No session → legacy single-user behaviour » en lecture `board.py:1420-1424`. *Fix : 401 sans session + sortir `/board/api/pages` de la liste CSRF. Effort : **S**.* - [x] **A7 — Création de pages sans session, CSRF-exempt** : `POST /board/api/pages` (`board.py:1381-1404`) lit la session mais **ne vérifie jamais `if not user`** (contrairement à `set_page_lock`, `board.py:128-130`), et `/board/api/pages` est exclu du CSRF ; même pattern « No session → legacy single-user behaviour » en lecture `board.py:1420-1424`. *Fix : 401 sans session + sortir `/board/api/pages` de la liste CSRF. Effort : **S**.*
- [x] **A8 — Mot de passe admin codé en dur, re-seedé à chaque boot** : `app/main.py:80` `hash_password("FlowDeck2026!")` puis `INSERT OR IGNORE ... 'admin' ... is_admin=1` (80-85). Literal commité + scannable + réappliqué si le hash est effacé. *Fix : mot de passe aléatoire au premier boot (affiché une fois) ou `FLOWDECK_ADMIN_PASSWORD` ; ne re-hasher qu'au premier démarrage. Effort : **XS**.* - [x] **A8 — Mot de passe admin codé en dur, re-seedé à chaque boot** : `app/main.py:80` `hash_password("FlowDeck2026!")` puis `INSERT OR IGNORE ... 'admin' ... is_admin=1` (80-85). Literal commité + scannable + réappliqué si le hash est effacé. *Fix : mot de passe aléatoire au premier boot (affiché une fois) ou `FLOWDECK_ADMIN_PASSWORD` ; ne re-hasher qu'au premier démarrage. Effort : **XS**.*
- [x] **A9 — DB de prod trackée dans git** : `flowdeck.db` (11 users, e-mails, `password_hash`, 9 sessions actives), `flowdeck_dev.db`, `test-commit.md`, `upload_test.txt` sont dans l'index **et** absents de `.gitignore` **et** de `.dockerignore` → `COPY . .` les embarque dans l'image. *Fix : `git rm --cached` + ajouter `*.db`, `*.db-*`, `test-commit.md`, `upload_test.txt`, `e2e/node_modules/`, `e2e/shots/` à `.gitignore` **et** `.dockerignore` + rotation du `app_secret_key` (les sessions sont révoquées). Effort : **S**.* - [x] **A9 — DB de prod trackée dans git** : `flowdeck.db` (11 users, e-mails, `password_hash`, 9 sessions actives), `flowdeck_dev.db`, `test-commit.md`, `upload_test.txt` sont dans l'index **et** absents de `.gitignore` **et** de `.dockerignore` → `COPY . .` les embarque dans l'image. *Fix : `git rm --cached` + ajouter `*.db`, `*.db-*`, `test-commit.md`, `upload_test.txt`, `e2e/node_modules/`, `e2e/shots/` à `.gitignore` **et** `.dockerignore` + rotation du `app_secret_key` (les sessions sont révoquées). Effort : **S**.*
- [ ] **A10 — Jinja2 `autoescape` désactivé partout** : les 29 sites construisent `Environment(loader=FileSystemLoader("app/templates"))` sans `autoescape` (vérifié à l'exécution : `autoescape = False`, jinja2 3.1.6 ; `grep autoescape app/*.py` → 0 hit). Résultat : 326 interpolations `{{ … }}` brutes dans 39 templates, et **tous les `|safe` du codebase sont des no-op**. Pannes concrètes : `notes.html:16,25` (`<textarea>{{ content }}</textarea>` + preview), `public_page.html:7,161` (titre non échappé sur les pages publiques `/s/`), `card_detail.html:48,85` (`issue.body|safe`, `comment.body|safe`), `base.html:140` (nom de page injecté en JS inline → exécution), `base.html:1789` (`innerHTML + item.name` depuis l'arbre Gitea), `base.html:1333` (`safeName` n'échappe que les guillemets, pas `<`/`>`). *Fix **à la racine** : un seul `app/templating.py` avec `ENV = Environment(loader=..., autoescape=select_autoescape(["html"]))`, remplacer les 29 instantiations, puis re-trier les `|safe`. Effort : **M**.* - [x] **A10 — Jinja2 `autoescape` désactivé partout** : les 29 sites construisent `Environment(loader=FileSystemLoader("app/templates"))` sans `autoescape` (vérifié à l'exécution : `autoescape = False`, jinja2 3.1.6 ; `grep autoescape app/*.py` → 0 hit). Résultat : 326 interpolations `{{ … }}` brutes dans 39 templates, et **tous les `|safe` du codebase sont des no-op**. Pannes concrètes : `notes.html:16,25` (`<textarea>{{ content }}</textarea>` + preview), `public_page.html:7,161` (titre non échappé sur les pages publiques `/s/`), `card_detail.html:48,85` (`issue.body|safe`, `comment.body|safe`), `base.html:140` (nom de page injecté en JS inline → exécution), `base.html:1789` (`innerHTML + item.name` depuis l'arbre Gitea), `base.html:1333` (`safeName` n'échappe que les guillemets, pas `<`/`>`). *Fix **à la racine** : un seul `app/templating.py` avec `ENV = Environment(loader=..., autoescape=select_autoescape(["html"]))`, remplacer les 29 instantiations, puis re-trier les `|safe`. Effort : **M**.*
- [ ] **A11 — Path traversal en lecture** : `GET /api/settings/avatar/{filename:path}` (`dashboard.py:1870-1877`) fait `Path("/data/avatars") / filename` puis `FileResponse` **sans `.resolve()` ni `relative_to()`** alors que le bon motif existe 40 lignes plus bas (`dashboard.py:1479-1484`). Le `:path` Starlette accepte les `/`. *Fix : copier la garde de `serve_uploaded_file`. Effort : **XS**.* - [ ] **A11 — Path traversal en lecture** : `GET /api/settings/avatar/{filename:path}` (`dashboard.py:1870-1877`) fait `Path("/data/avatars") / filename` puis `FileResponse` **sans `.resolve()` ni `relative_to()`** alors que le bon motif existe 40 lignes plus bas (`dashboard.py:1479-1484`). Le `:path` Starlette accepte les `/`. *Fix : copier la garde de `serve_uploaded_file`. Effort : **XS**.*
### 🟠 P1 — Hautes ### 🟠 P1 — Hautes
@@ -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. → 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`.** → **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.**
+1 -1
View File
@@ -1 +1 @@
7.3.0 7.3.1
+1 -1
View File
@@ -1,6 +1,6 @@
# WORKLOAD — FlowDeck Notion Clone # WORKLOAD — FlowDeck Notion Clone
> **Début**: 2026-07-08 | **Version**: v7.3.0 (cycle v7 — versions v6.8→v7.3 livrées) | **Statut**: EN COURS 🔄 > **Début**: 2026-07-08 | **Version**: v7.3.1 (audit sécurité P0 A1–A10) | **Statut**: EN COURS 🔄
> **Cible**: parité Notion + intégration forge · **Follow-ups v7.3 livrés**: sidebar teamspaces, notif `page.updated`, charts `number` + dashboards multi-DB, unfurl forge, UI Settings → Audit — voir `ROADMAP.md § v7.3.0` > **Cible**: parité Notion + intégration forge · **Follow-ups v7.3 livrés**: sidebar teamspaces, notif `page.updated`, charts `number` + dashboards multi-DB, unfurl forge, UI Settings → Audit — voir `ROADMAP.md § v7.3.0`
## Avancement Global ## Avancement Global
+1 -1
View File
@@ -153,7 +153,7 @@ async def lifespan(_app: FastAPI):
app = FastAPI( app = FastAPI(
title="FlowDeck", title="FlowDeck",
version="7.3.0", version="7.3.1",
docs_url="/docs", docs_url="/docs",
redoc_url="/redoc", redoc_url="/redoc",
lifespan=lifespan, lifespan=lifespan,
+2 -2
View File
@@ -440,8 +440,8 @@ async def get_issue_detail(owner: str, repo: str, issue_id: int, format: str = Q
"comments": comments, "comments": comments,
"checklists": checklists, "checklists": checklists,
} }
from jinja2 import Environment, FileSystemLoader from app.templating import ENV
env = Environment(loader=FileSystemLoader("app/templates")) env = ENV
template = env.get_template("card_detail.html") template = env.get_template("card_detail.html")
return HTMLResponse(template.render(**ctx)) return HTMLResponse(template.render(**ctx))
+11 -11
View File
@@ -804,7 +804,7 @@ def _sidebar_data(request: Request, owner: str = "", repo: str = "") -> dict:
"has_active_workspace": has_active_workspace, "has_active_workspace": has_active_workspace,
"app_version": _get_app_version(), "app_version": _get_app_version(),
"local_workspaces": _local_workspaces_for_user(user), "local_workspaces": _local_workspaces_for_user(user),
"sidebar_config": json.dumps(get_sidebar_config_sync(uid))} "sidebar_config": get_sidebar_config_sync(uid)}
def _extract_ai_keywords(owner: str, repo: str, labels: list[dict], body: str = ""): def _extract_ai_keywords(owner: str, repo: str, labels: list[dict], body: str = ""):
@@ -908,8 +908,8 @@ def _apply_sorts(cards: list[dict], sorts: str) -> list[dict]:
@router.get("/library", response_class=HTMLResponse) @router.get("/library", response_class=HTMLResponse)
async def library_page(request: Request, owner: str = Query(default=""), repo: str = Query(default="")): async def library_page(request: Request, owner: str = Query(default=""), repo: str = Query(default="")):
"""Library page avec onglets Recents, Favorites, Shared, Private — scopé au workspace.""" """Library page avec onglets Recents, Favorites, Shared, Private — scopé au workspace."""
from jinja2 import Environment, FileSystemLoader from app.templating import ENV
env = Environment(loader=FileSystemLoader("app/templates")) env = ENV
sidebar = _sidebar_data(request, owner, repo) sidebar = _sidebar_data(request, owner, repo)
# Load all pages for the workspace from DB # Load all pages for the workspace from DB
ws_key = f"{owner}/{repo}" if owner and repo else "" ws_key = f"{owner}/{repo}" if owner and repo else ""
@@ -1084,8 +1084,8 @@ async def permanent_delete(request: Request, page_id: int):
@router.get("/trash", response_class=HTMLResponse) @router.get("/trash", response_class=HTMLResponse)
async def trash_page(request: Request): async def trash_page(request: Request):
from jinja2 import Environment, FileSystemLoader from app.templating import ENV
env = Environment(loader=FileSystemLoader("app/templates")) env = ENV
template = env.get_template("trash.html") template = env.get_template("trash.html")
return template.render(**_sidebar_data(request)) return template.render(**_sidebar_data(request))
@@ -1213,8 +1213,8 @@ async def get_page_synced_refs(request: Request, page_id: int):
@router.get("/{owner}/{repo}", response_class=HTMLResponse) @router.get("/{owner}/{repo}", response_class=HTMLResponse)
async def board(request: Request, owner: str, repo: str, view: str = Query(default="")): async def board(request: Request, owner: str, repo: str, view: str = Query(default="")):
from jinja2 import Environment, FileSystemLoader from app.templating import ENV
env = Environment(loader=FileSystemLoader("app/templates")) env = ENV
sidebar = _sidebar_data(request, owner, repo) sidebar = _sidebar_data(request, owner, repo)
template = env.get_template("board.html") template = env.get_template("board.html")
return template.render(request=request, owner=owner, repo=repo, groups=[], return template.render(request=request, owner=owner, repo=repo, groups=[],
@@ -1240,8 +1240,8 @@ async def board_view(
logger.error("Board view error: %s", e) logger.error("Board view error: %s", e)
cards = [] cards = []
from jinja2 import Environment, FileSystemLoader from app.templating import ENV
env = Environment(loader=FileSystemLoader("app/templates")) env = ENV
# Dynamic groups from Gitea labels (fallback to hardcoded) # Dynamic groups from Gitea labels (fallback to hardcoded)
group_names = ["Design", "Engineering", "No Team"] group_names = ["Design", "Engineering", "No Team"]
@@ -2047,8 +2047,8 @@ async def view_page(request: Request, page_id: int):
"""Render a page as HTML, or a file viewer for uploaded files. """Render a page as HTML, or a file viewer for uploaded files.
?embed=1 — minimal mode for side peek (editor only, no header).""" ?embed=1 — minimal mode for side peek (editor only, no header)."""
embed = request.query_params.get("embed") == "1" embed = request.query_params.get("embed") == "1"
from jinja2 import Environment, FileSystemLoader from app.templating import ENV
env = Environment(loader=FileSystemLoader("app/templates")) env = ENV
# v6.0.0: granular page permissions — hide restricted pages (404). # v6.0.0: granular page permissions — hide restricted pages (404).
user_hdr = SessionManager.decode_session(request.cookies.get("flowdeck_session", "")) user_hdr = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
if user_hdr and user_hdr.get("id") and not PermissionManager(user_hdr["id"]).can_view_page(page_id): if user_hdr and user_hdr.get("id") and not PermissionManager(user_hdr["id"]).can_view_page(page_id):
+32 -36
View File
@@ -252,10 +252,9 @@ def _sidebar_data(request: Request, repos: list[dict], include_workspace: bool =
@router.get("/trash", response_class=HTMLResponse) @router.get("/trash", response_class=HTMLResponse)
async def trash_page(request: Request, owner: str = Query(default=""), repo: str = Query(default="")): async def trash_page(request: Request, owner: str = Query(default=""), repo: str = Query(default="")):
"""Trash page — scoped to workspace if owner/repo provided.""" """Trash page — scoped to workspace if owner/repo provided."""
from jinja2 import Environment, FileSystemLoader
from app.routers.board import _sidebar_data as board_sidebar from app.routers.board import _sidebar_data as board_sidebar
env = Environment(loader=FileSystemLoader("app/templates")) from app.templating import ENV
env = ENV
sidebar = board_sidebar(request, owner, repo) sidebar = board_sidebar(request, owner, repo)
with get_conn() as conn: with get_conn() as conn:
ws_key = f"{owner}/{repo}" if owner and repo else "" ws_key = f"{owner}/{repo}" if owner and repo else ""
@@ -283,10 +282,9 @@ async def library_page(request: Request, owner: str = Query(default=""), repo: s
Sidebar data is kept intact. Tab content is loaded client-side via /api/library/* endpoints. Sidebar data is kept intact. Tab content is loaded client-side via /api/library/* endpoints.
""" """
from jinja2 import Environment, FileSystemLoader
from app.routers.board import _sidebar_data as board_sidebar from app.routers.board import _sidebar_data as board_sidebar
env = Environment(loader=FileSystemLoader("app/templates")) from app.templating import ENV
env = ENV
sidebar = board_sidebar(request, owner, repo) sidebar = board_sidebar(request, owner, repo)
# Pass active workspace for breadcrumb nav menu # Pass active workspace for breadcrumb nav menu
ws_key_ws = f"{owner}/{repo}" if owner and repo else "" ws_key_ws = f"{owner}/{repo}" if owner and repo else ""
@@ -312,10 +310,9 @@ async def view_page_root(request: Request, page_id: int):
"""Render a Markdown page at root level with workspace context — or file viewer. """Render a Markdown page at root level with workspace context — or file viewer.
?embed=1 — minimal mode for side peek (editor only, no header).""" ?embed=1 — minimal mode for side peek (editor only, no header)."""
embed = request.query_params.get("embed") == "1" embed = request.query_params.get("embed") == "1"
from jinja2 import Environment, FileSystemLoader
from app.routers.board import _sidebar_data as board_sidebar from app.routers.board import _sidebar_data as board_sidebar
env = Environment(loader=FileSystemLoader("app/templates")) from app.templating import ENV
env = ENV
with get_conn() as conn: with get_conn() as conn:
row = conn.execute("SELECT * FROM pages WHERE id=?", (page_id,)).fetchone() row = conn.execute("SELECT * FROM pages WHERE id=?", (page_id,)).fetchone()
if not row: if not row:
@@ -437,8 +434,8 @@ async def view_page_root(request: Request, page_id: int):
@router.get("/accounts", response_class=HTMLResponse) @router.get("/accounts", response_class=HTMLResponse)
async def accounts_page(request: Request): async def accounts_page(request: Request):
"""Account management panel.""" """Account management panel."""
from jinja2 import Environment, FileSystemLoader from app.templating import ENV
env = Environment(loader=FileSystemLoader("app/templates")) env = ENV
sidebar = _sidebar_data(request, []) sidebar = _sidebar_data(request, [])
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", "")) user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
with get_conn() as conn: with get_conn() as conn:
@@ -451,8 +448,8 @@ async def accounts_page(request: Request):
@router.get("/help", response_class=HTMLResponse) @router.get("/help", response_class=HTMLResponse)
async def help_page(request: Request): async def help_page(request: Request):
"""Comprehensive help & documentation page.""" """Comprehensive help & documentation page."""
from jinja2 import Environment, FileSystemLoader from app.templating import ENV
env = Environment(loader=FileSystemLoader("app/templates")) env = ENV
sidebar = _sidebar_data(request, []) sidebar = _sidebar_data(request, [])
# Render via a block-based template so content_html lands in {% block content %} # Render via a block-based template so content_html lands in {% block content %}
block_tpl = env.from_string( block_tpl = env.from_string(
@@ -656,8 +653,8 @@ favorites, tags, sharing, sprints and templates; pagination (<code>?limit=&amp;o
@router.get("/accounts/settings", response_class=HTMLResponse) @router.get("/accounts/settings", response_class=HTMLResponse)
async def settings_page(request: Request): async def settings_page(request: Request):
"""User settings page — profile, forges, tokens.""" """User settings page — profile, forges, tokens."""
from jinja2 import Environment, FileSystemLoader from app.templating import ENV
env = Environment(loader=FileSystemLoader("app/templates")) env = ENV
sidebar = _sidebar_data(request, []) sidebar = _sidebar_data(request, [])
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", "")) user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
if not user: if not user:
@@ -769,14 +766,14 @@ async def dashboard(
with get_conn() as conn: with get_conn() as conn:
count = conn.execute("SELECT COUNT(*) FROM users").fetchone()[0] count = conn.execute("SELECT COUNT(*) FROM users").fetchone()[0]
if count == 0: if count == 0:
from jinja2 import Environment, FileSystemLoader from app.templating import ENV
env = Environment(loader=FileSystemLoader("app/templates")) env = ENV
template = env.get_template("landing.html") template = env.get_template("landing.html")
return template.render() return template.render()
except Exception: except Exception:
pass pass
from jinja2 import Environment, FileSystemLoader from app.templating import ENV
env = Environment(loader=FileSystemLoader("app/templates")) env = ENV
template = env.get_template("landing.html") template = env.get_template("landing.html")
return template.render() return template.render()
@@ -821,8 +818,8 @@ async def dashboard(
logger.error("Dashboard error: %s", e) logger.error("Dashboard error: %s", e)
repos = [] repos = []
from jinja2 import Environment, FileSystemLoader from app.templating import ENV
env = Environment(loader=FileSystemLoader("app/templates")) env = ENV
sidebar = _sidebar_data(request, repos) sidebar = _sidebar_data(request, repos)
template = env.get_template("dashboard.html") template = env.get_template("dashboard.html")
return template.render(request=request, repos=repos, search=search, return template.render(request=request, repos=repos, search=search,
@@ -834,8 +831,8 @@ async def dashboard(
@router.get("/workspace", response_class=HTMLResponse) @router.get("/workspace", response_class=HTMLResponse)
async def workspace_page(request: Request): async def workspace_page(request: Request):
"""Unified workspace showing all projects.""" """Unified workspace showing all projects."""
from jinja2 import Environment, FileSystemLoader from app.templating import ENV
env = Environment(loader=FileSystemLoader("app/templates")) env = ENV
sidebar = _sidebar_data(request, []) sidebar = _sidebar_data(request, [])
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", "")) user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
if not user: if not user:
@@ -853,8 +850,8 @@ async def gitea_workspace_page(request: Request):
"""Gitea workspace — browse repo files.""" """Gitea workspace — browse repo files."""
import json import json
from jinja2 import Environment, FileSystemLoader from app.templating import ENV
env = Environment(loader=FileSystemLoader("app/templates")) env = ENV
sidebar = _sidebar_data(request, []) sidebar = _sidebar_data(request, [])
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", "")) user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
if not user: if not user:
@@ -1030,8 +1027,8 @@ async def local_workspace_page(request: Request, folder: int = None):
If ?folder=ID is provided, shows that folder's contents with breadcrumb. If ?folder=ID is provided, shows that folder's contents with breadcrumb.
""" """
from jinja2 import Environment, FileSystemLoader from app.templating import ENV
env = Environment(loader=FileSystemLoader("app/templates")) env = ENV
sidebar = _sidebar_data(request, []) sidebar = _sidebar_data(request, [])
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", "")) user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
if not user: if not user:
@@ -1735,8 +1732,8 @@ def _get_active_workspace(request: Request, user_id: int = None) -> dict | None:
@router.get("/workspaces", response_class=HTMLResponse) @router.get("/workspaces", response_class=HTMLResponse)
async def workspaces_page(request: Request): async def workspaces_page(request: Request):
"""Workspaces list page.""" """Workspaces list page."""
from jinja2 import Environment, FileSystemLoader from app.templating import ENV
env = Environment(loader=FileSystemLoader("app/templates")) env = ENV
sidebar = _sidebar_data(request, [], include_workspace=False) sidebar = _sidebar_data(request, [], include_workspace=False)
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", "")) user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
if not user: if not user:
@@ -1838,8 +1835,8 @@ async def select_workspace(request: Request, ws_id: int):
@router.get("/settings", response_class=HTMLResponse) @router.get("/settings", response_class=HTMLResponse)
async def app_settings_page(request: Request): async def app_settings_page(request: Request):
"""Settings & configuration page.""" """Settings & configuration page."""
from jinja2 import Environment, FileSystemLoader from app.templating import ENV
env = Environment(loader=FileSystemLoader("app/templates")) env = ENV
sidebar = _sidebar_data(request, []) sidebar = _sidebar_data(request, [])
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", "")) user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
if not user: if not user:
@@ -2156,9 +2153,8 @@ async def sidebar_workspace_tree(request: Request):
Called by appState().refreshSidebarTree() after CRUD operations Called by appState().refreshSidebarTree() after CRUD operations
in the main content area to keep the sidebar in sync. in the main content area to keep the sidebar in sync.
""" """
from jinja2 import Environment, FileSystemLoader
from app.routers.board import _load_workspace_pages from app.routers.board import _load_workspace_pages
from app.templating import ENV
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", "")) user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
if not user: if not user:
@@ -2194,7 +2190,7 @@ async def sidebar_workspace_tree(request: Request):
) )
# Render the tree using the extracted macro # Render the tree using the extracted macro
env = Environment(loader=FileSystemLoader("app/templates")) env = ENV
template = env.from_string( template = env.from_string(
"{% from '_workspace_tree_macro.html' import render_workspace_tree %}" "{% from '_workspace_tree_macro.html' import render_workspace_tree %}"
"{{ render_workspace_tree(pages) }}" "{{ render_workspace_tree(pages) }}"
@@ -2215,7 +2211,7 @@ async def sidebar_workspace_tree(request: Request):
@router.get("/p/{slug}", response_class=HTMLResponse) @router.get("/p/{slug}", response_class=HTMLResponse)
async def public_published_page(request: Request, slug: str): async def public_published_page(request: Request, slug: str):
"""Serve a published page at /p/<slug> — no auth required.""" """Serve a published page at /p/<slug> — no auth required."""
from jinja2 import Environment, FileSystemLoader from app.templating import ENV
with get_conn() as conn: with get_conn() as conn:
row = conn.execute( row = conn.execute(
@@ -2235,7 +2231,7 @@ h1{font-size:3rem;opacity:.3}</style></head><body><h1>404</h1></body></html>""",
) )
page = dict(row) page = dict(row)
env = Environment(loader=FileSystemLoader("app/templates")) env = ENV
# Convert blocks to HTML for rendering # Convert blocks to HTML for rendering
content_html = "" content_html = ""
+2 -2
View File
@@ -44,9 +44,9 @@ async def import_page(request: Request):
user = _current_user(request) user = _current_user(request)
if not user: if not user:
return RedirectResponse("/auth/login?provider=local", status_code=302) return RedirectResponse("/auth/login?provider=local", status_code=302)
from jinja2 import Environment, FileSystemLoader from app.templating import ENV
env = Environment(loader=FileSystemLoader("app/templates")) env = ENV
return HTMLResponse(content=env.get_template("import.html").render(user=user)) return HTMLResponse(content=env.get_template("import.html").render(user=user))
+2 -2
View File
@@ -6,10 +6,10 @@ import logging
from fastapi import APIRouter, Request from fastapi import APIRouter, Request
from fastapi.responses import HTMLResponse from fastapi.responses import HTMLResponse
from jinja2 import Environment, FileSystemLoader
from app.auth.session import SessionManager from app.auth.session import SessionManager
from app.db import get_conn from app.db import get_conn
from app.templating import ENV
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
router = APIRouter(tags=["my-tasks"], prefix="/my-tasks") router = APIRouter(tags=["my-tasks"], prefix="/my-tasks")
@@ -102,7 +102,7 @@ async def my_tasks_dashboard(request: Request, view: str = "all", days: int = 7)
</div>""" </div>"""
from app.routers.dashboard import _sidebar_data from app.routers.dashboard import _sidebar_data
env = Environment(loader=FileSystemLoader("app/templates")) env = ENV
sidebar = _sidebar_data(request, []) sidebar = _sidebar_data(request, [])
block_tpl = env.from_string( block_tpl = env.from_string(
'{% extends "base.html" %}{% block content %}{{ content_html|safe }}{% endblock %}' '{% extends "base.html" %}{% block content %}{{ content_html|safe }}{% endblock %}'
+4 -6
View File
@@ -21,10 +21,9 @@ async def get_notes(request: Request, owner: str, repo: str):
).fetchone() ).fetchone()
content = row["content"] if row else "" content = row["content"] if row else ""
from jinja2 import Environment, FileSystemLoader
from app.auth.session import SessionManager from app.auth.session import SessionManager
env = Environment(loader=FileSystemLoader("app/templates")) from app.templating import ENV
env = ENV
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", "")) user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
template = env.get_template("notes.html") template = env.get_template("notes.html")
return template.render(request=request, owner=owner, repo=repo, content=content, user=user) return template.render(request=request, owner=owner, repo=repo, content=content, user=user)
@@ -45,10 +44,9 @@ async def save_notes(request: Request, owner: str, repo: str):
) )
conn.commit() conn.commit()
from jinja2 import Environment, FileSystemLoader
from app.auth.session import SessionManager from app.auth.session import SessionManager
env = Environment(loader=FileSystemLoader("app/templates")) from app.templating import ENV
env = ENV
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", "")) user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
template = env.get_template("notes.html") template = env.get_template("notes.html")
return template.render(request=request, owner=owner, repo=repo, content=content, user=user) return template.render(request=request, owner=owner, repo=repo, content=content, user=user)
+2 -2
View File
@@ -41,8 +41,8 @@ async def onboarding_page(request: Request):
if ws_count > 0: if ws_count > 0:
return RedirectResponse("/workspaces", status_code=302) return RedirectResponse("/workspaces", status_code=302)
from jinja2 import Environment, FileSystemLoader from app.templating import ENV
env = Environment(loader=FileSystemLoader("app/templates")) env = ENV
template = env.get_template("welcome.html") template = env.get_template("welcome.html")
return HTMLResponse(content=template.render( return HTMLResponse(content=template.render(
user=user, user=user,
+2 -3
View File
@@ -228,11 +228,10 @@ async def revoke_extension_device(device_id: int, request: Request):
@router.get("/extensions", response_class=HTMLResponse) @router.get("/extensions", response_class=HTMLResponse)
async def extensions_page(request: Request): async def extensions_page(request: Request):
from jinja2 import Environment, FileSystemLoader
from app.routers.dashboard import _sidebar_data from app.routers.dashboard import _sidebar_data
from app.templating import ENV
env = Environment(loader=FileSystemLoader("app/templates")) env = ENV
try: try:
sidebar = _sidebar_data(request, []) sidebar = _sidebar_data(request, [])
except Exception: except Exception:
+2 -2
View File
@@ -9,13 +9,13 @@ import html
from fastapi import APIRouter, HTTPException, Request from fastapi import APIRouter, HTTPException, Request
from fastapi.responses import HTMLResponse, JSONResponse from fastapi.responses import HTMLResponse, JSONResponse
from jinja2 import Environment, FileSystemLoader
from app.auth.session import SessionManager from app.auth.session import SessionManager
from app.db import get_conn from app.db import get_conn
from app.services import wiki from app.services import wiki
from app.services.api_v2_helpers import audit_log from app.services.api_v2_helpers import audit_log
from app.services.notifications import create_notification from app.services.notifications import create_notification
from app.templating import ENV
router = APIRouter(tags=["wiki"]) router = APIRouter(tags=["wiki"])
@@ -156,7 +156,7 @@ async def teamspace_page(teamspace_id: int, request: Request):
.ts-row:hover{{background:var(--bg-hover);}} .ts-row:hover{{background:var(--bg-hover);}}
</style>""" </style>"""
from app.routers.dashboard import _sidebar_data from app.routers.dashboard import _sidebar_data
env = Environment(loader=FileSystemLoader("app/templates")) env = ENV
sidebar = _sidebar_data(request, []) sidebar = _sidebar_data(request, [])
block_tpl = env.from_string( block_tpl = env.from_string(
'{% extends "base.html" %}{% block content %}{{ content_html|safe }}{% endblock %}' '{% extends "base.html" %}{% block content %}{{ content_html|safe }}{% endblock %}'
+1 -1
View File
@@ -1370,7 +1370,7 @@
loadSidebarConfig() { loadSidebarConfig() {
var self = this; var self = this;
try { try {
var raw = {{ sidebar_config|default('{}')|safe }}; var raw = {{ sidebar_config|default({})|tojson }};
if (raw && raw.config) { if (raw && raw.config) {
self.sidebarConfig = raw.config; self.sidebarConfig = raw.config;
} else if (typeof raw === 'object') { } else if (typeof raw === 'object') {
+2 -2
View File
@@ -45,7 +45,7 @@
<div style="margin-bottom:16px; padding:12px; background:var(--bg-secondary); border-radius:6px; min-height:60px;" <div style="margin-bottom:16px; padding:12px; background:var(--bg-secondary); border-radius:6px; min-height:60px;"
contenteditable="true" contenteditable="true"
@blur="updateField('body', $event.target.innerHTML)"> @blur="updateField('body', $event.target.innerHTML)">
{{ issue.body|safe if issue.body else '<span style="color:var(--text-dim);">Add description...</span>' }} {% if issue.body %}{{ issue.body }}{% else %}<span style="color:var(--text-dim);">Add description...</span>{% endif %}
</div> </div>
<!-- Checklists --> <!-- Checklists -->
@@ -82,7 +82,7 @@
<span class="text-dim" style="font-weight:400;">· {{ comment.created_at[:10] }}</span> <span class="text-dim" style="font-weight:400;">· {{ comment.created_at[:10] }}</span>
</div> </div>
<div style="font-size:13px; color:var(--text-primary); line-height:1.5;"> <div style="font-size:13px; color:var(--text-primary); line-height:1.5;">
{{ comment.body|safe }} {{ comment.body }}
</div> </div>
</div> </div>
</div> </div>
+14
View File
@@ -0,0 +1,14 @@
"""Environment Jinja2 partagé (A10 : autoescape activé partout).
Une seule instance au lieu de 29 `Environment(loader=FileSystemLoader(...))`
sans autoescape — 326 interpolations `{{ … }}` étaient servies crues et tous
les `|safe` du codebase étaient des no-op.
"""
from __future__ import annotations
from jinja2 import Environment, FileSystemLoader, select_autoescape
ENV = Environment(
loader=FileSystemLoader("app/templates"),
autoescape=select_autoescape(["html"]),
)