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 = [
+ '">