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:
Jacquin Antoine
2026-07-20 00:29:02 +02:00
parent dda8a83114
commit c86e1aaa13
4 changed files with 173 additions and 3 deletions

View File

@ -8,6 +8,7 @@ import re
import secrets import secrets
from datetime import datetime, timedelta from datetime import datetime, timedelta
from itertools import groupby from itertools import groupby
from urllib.parse import urlsplit
import bcrypt import bcrypt
from fastapi import FastAPI, Form, Request, Response from fastapi import FastAPI, Form, Request, Response
@ -122,6 +123,50 @@ def smtp_config() -> dict:
return load_config().get("smtp", {}) return load_config().get("smtp", {})
def _safe_base_url(request: Request) -> str | None:
"""Retourne une URL de base fiable pour construire des liens (emails, etc.).
Anti open-redirect / phishing : le header Host est contrôlable par le client
(curl/DevTools), donc `request.base_url` ne doit pas être utilisée telle
quelle dans un email envoyé à l'utilisateur, sinon un attaquant peut faire
envoyer un mail légitime contenant `https://phishing.attacker/set-password/<token>`
avec un token réellement valide.
Stratégie :
- Si `base_url` est défini en config, l'utiliser (schéma http/https requis).
- Sinon, accepter le Host seulement s'il correspond à `allowed_email_domain`
(suffixe strict) ou à localhost (utile pour les tests/dev locaux).
- Sinon, retourner None : l'appelant doit refuser l'envoi.
"""
cfg = load_config()
configured = (cfg.get("base_url") or "").strip().rstrip("/")
if configured:
try:
parts = urlsplit(configured)
if parts.scheme in ("http", "https") and parts.hostname:
return configured
except ValueError:
pass
logger.warning("base_url configurée invalide: %r", configured)
return None
host = (request.headers.get("host") or "").strip()
if not host:
return None
host_l = host.lower()
hostname = host_l.split(":", 1)[0]
scheme = "https" if request.url.scheme == "https" else "http"
if hostname in ("localhost", "127.0.0.1", "::1"):
return f"{scheme}://{host}"
allowed = (cfg.get("allowed_email_domain") or "").strip().lower()
if allowed and (host_l == allowed or host_l.endswith("." + allowed)):
return f"{scheme}://{host}"
return None
def _require_user(request: Request): def _require_user(request: Request):
"""Return user_id or redirect to login.""" """Return user_id or redirect to login."""
uid = _get_user(request) uid = _get_user(request)
@ -327,12 +372,22 @@ def login_submit(request: Request, email: str = Form("")):
return templates.TemplateResponse("login.html", _login_ctx(request, "password", email)) return templates.TemplateResponse("login.html", _login_ctx(request, "password", email))
# No account yet: (re)send a link to set the password # No account yet: (re)send a link to set the password
base_url = _safe_base_url(request)
if not base_url:
logger.warning(
"login refusé (Host non fiable pour générer un lien email): host=%r email=%r",
request.headers.get("host"), email,
)
return templates.TemplateResponse("login.html", _login_ctx(
request, "email", email,
"Domaine non autorisé pour générer un lien de connexion.",
))
token = secrets.token_urlsafe(32) token = secrets.token_urlsafe(32)
expires = (datetime.now() + timedelta(hours=RESET_TOKEN_VALIDITY_HOURS)).isoformat() expires = (datetime.now() + timedelta(hours=RESET_TOKEN_VALIDITY_HOURS)).isoformat()
save_auth(user_id, email=email, token=token, token_expires=expires) save_auth(user_id, email=email, token=token, token_expires=expires)
logger.info("nouveau token de création de compte pour %r (expire %s)", user_id, expires) logger.info("nouveau token de création de compte pour %r (expire %s)", user_id, expires)
base_url = str(request.base_url).rstrip("/")
link = f"{base_url}/set-password/{token}" link = f"{base_url}/set-password/{token}"
try: try:
send_mail( send_mail(
@ -601,7 +656,10 @@ def settings_page(request: Request):
notif = load_notif_config(user_id) notif = load_notif_config(user_id)
plages = load_plages(user_id) plages = load_plages(user_id)
base_url = str(request.base_url).rstrip("/") # Les URLs Tasker sont affichées à l'utilisateur lui-même : moins sensible qu'un
# email, mais on utilise quand même le helper (fallback sur base_url request si
# non configurée, car l'utilisateur voit sa propre URL).
base_url = _safe_base_url(request) or str(request.base_url).rstrip("/")
return templates.TemplateResponse("settings.html", { return templates.TemplateResponse("settings.html", {
"request": request, "request": request,
"current_user": user_id, "current_user": user_id,
@ -694,7 +752,7 @@ def settings_regenerate_presence_token(request: Request):
logger.warning("token de présence régénéré pour %r", user_id) logger.warning("token de présence régénéré pour %r", user_id)
append_log(user_id, "settings", "Token présence régénéré", "Les URLs Tasker ont été invalidées et remplacées.") append_log(user_id, "settings", "Token présence régénéré", "Les URLs Tasker ont été invalidées et remplacées.")
base_url = str(request.base_url).rstrip("/") base_url = _safe_base_url(request) or str(request.base_url).rstrip("/")
arrivee_url = f"{base_url}/presence/{new_token}/arrivee" arrivee_url = f"{base_url}/presence/{new_token}/arrivee"
depart_url = f"{base_url}/presence/{new_token}/depart" depart_url = f"{base_url}/presence/{new_token}/depart"
return HTMLResponse( return HTMLResponse(

View File

@ -13,6 +13,7 @@ DEFAULT_CONFIG = {
"heures_jour": 7.8, # 7h48 = 39h/semaine "heures_jour": 7.8, # 7h48 = 39h/semaine
"log_level": "DEBUG", # DEBUG, INFO, WARNING ou ERROR "log_level": "DEBUG", # DEBUG, INFO, WARNING ou ERROR
"allowed_email_domain": "", # ex: "sdv.fr" — seules ces adresses peuvent se connecter "allowed_email_domain": "", # ex: "sdv.fr" — seules ces adresses peuvent se connecter
"base_url": "", # ex: "https://pointeuse.sdv.fr" — utilisée pour les liens emails (anti open-redirect via Host header)
"mail_from": "", # ex: "no-reply@sdv.fr" — expéditeur des emails de connexion "mail_from": "", # ex: "no-reply@sdv.fr" — expéditeur des emails de connexion
"smtp": { "smtp": {
"host": "", # ex: "smtp.sdv.fr" "host": "", # ex: "smtp.sdv.fr"

View File

@ -227,3 +227,113 @@ def test_oob_html_escapes_date_attribute():
}) })
assert "<script" not in r.text.lower() assert "<script" not in r.text.lower()
assert "onload" 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

View File

@ -2,6 +2,7 @@
"heures_jour": 7.8, "heures_jour": 7.8,
"log_level": "DEBUG", "log_level": "DEBUG",
"allowed_email_domain": "exemple.fr", "allowed_email_domain": "exemple.fr",
"base_url": "",
"mail_from": "no-reply@exemple.fr", "mail_from": "no-reply@exemple.fr",
"smtp": { "smtp": {
"host": "smtp.exemple.fr", "host": "smtp.exemple.fr",