Bloque l'open redirect / phishing via le header Host
POST /login construisait le lien set-password envoyé par email avec
request.base_url, qui reflète directement le header Host contrôlable par le
client. Un attaquant pouvait envoyer POST /login avec Host: phishing.attacker
et déclencher l'envoi d'un email légitime contenant un lien vers son site,
avec un token réellement valide : phishing direct du mot de passe.
Ajout de main._safe_base_url(request) qui :
- utilise config.base_url si définie (nouvelle clé, schéma http/https requis) ;
- sinon accepte le Host seulement s'il correspond à allowed_email_domain
(suffixe strict avec un point) ou à localhost/127.0.0.1/::1 (dev local) ;
- sinon retourne None.
POST /login refuse désormais l'envoi du mail si le Host n'est pas validé
(message "Domaine non autorisé"). Les routes /settings (affichage des URLs
Tasker à l'utilisateur lui-même) utilisent le helper avec fallback sur
request.base_url, car le HTML n'est pas envoyé par email.
data/config.example.json documente la nouvelle clé base_url. Les tests
mockent send_mail et vérifient : rejet d'un Host arbitraire, acceptation
d'un host suffixe du domaine autorisé, acceptation de localhost, primauté
de config.base_url sur le Host, et cas unitaires du helper.
💘 Generated with Crush
Assisted-by: Crush:glm-5.2
This commit is contained in:
@ -227,3 +227,113 @@ def test_oob_html_escapes_date_attribute():
|
||||
})
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user