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:
64
app/main.py
64
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/<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(
|
||||
|
||||
Reference in New Issue
Block a user