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(