From dda8a8311425edb1af6c2b98f478801af7247c6b Mon Sep 17 00:00:00 2001 From: Jacquin Antoine Date: Mon, 20 Jul 2026 00:26:01 +0200 Subject: [PATCH] =?UTF-8?q?Bloque=20le=20XSS=20stock=C3=A9=20via=20/pointa?= =?UTF-8?q?ge=20et=20=C3=A9chappe=20les=20fragments=20HTMX?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit POST /pointage acceptait n'importe quelle valeur pour les 4 champs matin_entree/matin_sortie/aprem_entree/aprem_sortie : la fonction clean ne faisait qu'un strip(). Comme ces valeurs sont réinjectées telles quelles dans des f-strings HTML hors templates Jinja (qui seuls bénéficient de l'autoescape), un payload type `">` déclenchait une exécution JS immédiate dans la réponse HTMX, sans CSP pour limiter l'impact. Double défense appliquée : 1. Validation stricte en entrée : clean() refuse désormais tout ce qui ne matche pas _HHMM_RE (^([01]\d|2[0-3]):[0-5]\d$). Une valeur non conforme est traitée comme vide plutôt que stockée. 2. Échappement HTML systématique en sortie : un alias `_e = html.escape` est appliqué à toutes les valeurs dynamiques (date, heures, cibles) dans _oob_calc, _oob_week_extras, _oob_time_cells et _htmx_conge_and_soldes. Les ids, data-date, value, hx-post et contenus textuels sont protégés. Les nouveaux tests test_security couvrent plusieurs payloads XSS, vérifient que la valeur n'est ni persistée ni reflétée, et qu'un pointage HHMM valide continue à fonctionner. 💘 Generated with Crush Assisted-by: Crush:glm-5.2 --- app/main.py | 46 ++++++++++++++++----------- app/tests/test_security.py | 65 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 92 insertions(+), 19 deletions(-) diff --git a/app/main.py b/app/main.py index f57f98b..1a9d510 100644 --- a/app/main.py +++ b/app/main.py @@ -1,6 +1,7 @@ import asyncio import hashlib import hmac +import html import json import logging import re @@ -63,6 +64,10 @@ _USERNAME_RE = re.compile(r"^[a-zA-Z0-9_.\-]{1,64}$") _EMAIL_RE = re.compile(r"^([a-zA-Z0-9_.\-]{1,64})@([a-zA-Z0-9.\-]{1,255})$") _HHMM_RE = re.compile(r"^([01]\d|2[0-3]):[0-5]\d$") +# Échappement HTML pour les fragments construits en f-string hors templates Jinja +# (autoescape ne s'applique qu'aux templates). Defense-in-depth contre le XSS stocké. +_e = html.escape + # ── Auth helpers ────────────────────────────────────────────────────────────── @@ -173,16 +178,16 @@ def _build_jour_ctx(date, conges, h_jour, user_id): def _oob_calc(date, jour): - trav_inner = f'{"—" if not jour["travaille_min"] else jour["travaille"]}' + trav_inner = f'{"—" if not jour["travaille_min"] else _e(jour["travaille"])}' if jour["travaille_min"]: cls = "delta-pos" if jour["delta_min"] > 0 else ("delta-neg" if jour["delta_min"] < 0 else "delta-zero") sign = "+" if jour["delta_min"] > 0 else "" - delta_inner = f'{sign}{jour["delta"]}' + delta_inner = f'{sign}{_e(jour["delta"])}' else: delta_inner = '' return ( - f'{trav_inner}' - f'{delta_inner}' + f'{trav_inner}' + f'{delta_inner}' ) @@ -200,14 +205,14 @@ def _oob_week_extras(result): cumul_inner = f'{sign}{jour["delta_cumul"]}' else: cumul_inner = '' - html += f'{cumul_inner}' + html += f'{cumul_inner}' parts = [] if jour.get("cible_matin"): - parts.append(f'M {jour["cible_matin_entree"]} → {jour["cible_matin"]}') + parts.append(f'M {_e(jour["cible_matin_entree"])} → {_e(jour["cible_matin"])}') if jour.get("cible_aprem"): - parts.append(f'A {jour["cible_aprem_entree"]} → {jour["cible_aprem"]}') + parts.append(f'A {_e(jour["cible_aprem_entree"])} → {_e(jour["cible_aprem"])}') cible_inner = " ".join(parts) if parts else '' - html += f'{cible_inner}' + html += f'{cible_inner}' return html @@ -226,19 +231,19 @@ def _oob_time_cells(date, jour, plages): matin_cls = "td-fill cg" if matin_cg else ("td-fill warn" if matin_warn else "td-fill") aprem_cls = "td-fill cg" if aprem_cg else ("td-fill warn" if aprem_warn else "td-fill") matin_html = ( - f'
' + f'
' f'
' - f'' + f'' f'' - f'' + f'' f'
' ) aprem_html = ( - f'
' + f'
' f'
' - f'' + f'' f'' - f'' + f'' f'
' ) return matin_html + aprem_html @@ -253,11 +258,11 @@ def _htmx_conge_and_soldes(request, date, d, conges, h_jour, user_id): btn_am_on = " on" if "aprem" in cg else "" btn_j_on = " jour-on" if "jour" in cg else "" btns_html = ( - f'
' + f'
' f'
' - f'' - f'' - f'' + f'' + f'' + f'' f'
' ) soldes_ctx = _build_soldes_ctx(iso.year, iso.week, conges, h_jour, user_id) @@ -482,7 +487,10 @@ def save_pointage( return RedirectResponse("/login", status_code=302) def clean(v): - v = v.strip(); return v if v else None + # Refuse tout ce qui n'est pas un HHMM strict : empêche le XSS stocké car la + # valeur est réinjectée telle quelle dans des f-strings HTML hors templates. + v = v.strip() + return v if v and _HHMM_RE.match(v) else None d = datetime.strptime(date, "%Y-%m-%d").date() iso = d.isocalendar() diff --git a/app/tests/test_security.py b/app/tests/test_security.py index bfc0635..03aa038 100644 --- a/app/tests/test_security.py +++ b/app/tests/test_security.py @@ -162,3 +162,68 @@ def test_settings_save_rejects_ssrf_ntfy_server(): # La valeur précédente est conservée cfg = models.load_notif_config("carol") assert cfg["ntfy_server"] == "https://ntfy.sh" + + +# ── XSS stocké via /pointage ────────────────────────────────────────────────── + +XSS_PAYLOADS = [ + '">', + '">', + "'/>", +] + + +def test_pointage_rejects_non_hhmm_value(): + """POST /pointage avec une valeur non HHMM ne doit pas être persistée.""" + cookies = _login("dave") + for payload in XSS_PAYLOADS: + r = client.post("/pointage/2026-07-17", cookies=cookies, data={ + "matin_entree": payload, + "matin_sortie": "", + "aprem_entree": "", + "aprem_sortie": "", + }) + # La réponse ne doit pas planter + assert r.status_code == 200 + # Le payload ne doit pas se retrouver tel quel dans la réponse + assert payload not in r.text, payload + # Vérification de la persistance + week = models.load_week_pointages(2026, 29, "dave") + entry = next((p for p in week if p["date"] == "2026-07-17"), None) + assert entry is not None + # matin_entree doit avoir été ignoré (None), pas stocké tel quel + assert entry["matin_entree"] is None + + +def test_pointage_valid_hhmm_still_works(): + """Un pointage HHMM valide doit continuer à fonctionner.""" + cookies = _login("erin") + r = client.post("/pointage/2026-07-17", cookies=cookies, data={ + "matin_entree": "08:45", + "matin_sortie": "12:00", + "aprem_entree": "", + "aprem_sortie": "", + }) + assert r.status_code == 200 + assert "08:45" in r.text + week = models.load_week_pointages(2026, 29, "erin") + entry = next((p for p in week if p["date"] == "2026-07-17"), None) + assert entry["matin_entree"] == "08:45" + + +def test_oob_html_escapes_date_attribute(): + """Si la date (issue de l'URL) contient des caractères HTML, ils doivent être échappés.""" + cookies = _login("frank") + # Forçage d'un pointage valide pour déclencher le rendu _oob_time_cells + client.post("/pointage/2026-07-17", cookies=cookies, data={ + "matin_entree": "08:45", "matin_sortie": "", "aprem_entree": "", "aprem_sortie": "", + }) + # Une date malicieuse dans /refresh/... doit être échappée si elle passe strptime + # (les caractères HTML ne passent pas strptime, mais on vérifie que l'escape est actif + # sur le rendu d'un pointage normal pour la date légitime) + r = client.post("/pointage/2026-07-17", cookies=cookies, data={ + "matin_entree": "08:45", "matin_sortie": "12:00", + "aprem_entree": "14:00", "aprem_sortie": "17:30", + }) + assert "