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 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'<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"]:
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'<span class="{cls}">{sign}{jour["delta"]}</span>'
delta_inner = f'<span class="{cls}">{sign}{_e(jour["delta"])}</span>'
else:
delta_inner = '<span class="delta-zero">—</span>'
return (
f'<span id="calc-{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)}-trav" hx-swap-oob="true">{trav_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>'
else:
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 = []
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"):
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>'
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
@ -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'<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'<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'<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>'
)
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'<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'<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>'
)
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'<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'<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/{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)}/matin" hx-swap="none" class="btn-cg{btn_ma_on}" title="Congé matin">MA</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/{_e(date)}/jour" hx-swap="none" class="btn-cg btn-jour{btn_j_on}" title="Journée entière">J</button>'
f'</div></div>'
)
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()