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
|
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(
|
||||||
|
|||||||
@ -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"
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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",
|
||||||
|
|||||||
Reference in New Issue
Block a user