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
from datetime import datetime, timedelta
from itertools import groupby
from urllib.parse import urlsplit
import bcrypt
from fastapi import FastAPI, Form, Request, Response
@ -122,6 +123,50 @@ def smtp_config() -> dict:
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):
"""Return user_id or redirect to login."""
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))
# 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)
expires = (datetime.now() + timedelta(hours=RESET_TOKEN_VALIDITY_HOURS)).isoformat()
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)
base_url = str(request.base_url).rstrip("/")
link = f"{base_url}/set-password/{token}"
try:
send_mail(
@ -601,7 +656,10 @@ def settings_page(request: Request):
notif = load_notif_config(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", {
"request": request,
"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)
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"
depart_url = f"{base_url}/presence/{new_token}/depart"
return HTMLResponse(

View File

@ -13,6 +13,7 @@ DEFAULT_CONFIG = {
"heures_jour": 7.8, # 7h48 = 39h/semaine
"log_level": "DEBUG", # DEBUG, INFO, WARNING ou ERROR
"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
"smtp": {
"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 "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,
"log_level": "DEBUG",
"allowed_email_domain": "exemple.fr",
"base_url": "",
"mail_from": "no-reply@exemple.fr",
"smtp": {
"host": "smtp.exemple.fr",