diff --git a/app/main.py b/app/main.py index 1a9d510..86232f6 100644 --- a/app/main.py +++ b/app/main.py @@ -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/` + 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( diff --git a/app/models.py b/app/models.py index 2672c0e..1f5e8fb 100644 --- a/app/models.py +++ b/app/models.py @@ -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" diff --git a/app/tests/test_security.py b/app/tests/test_security.py index 03aa038..a0a4e7d 100644 --- a/app/tests/test_security.py +++ b/app/tests/test_security.py @@ -227,3 +227,113 @@ def test_oob_html_escapes_date_attribute(): }) assert "