Bloque le SSRF via ntfy_server par validation stricte

Le champ ntfy_server du formulaire /settings était utilisé tel quel dans
urllib.request.urlopen sans aucune validation. Un attaquant pouvait pointer
le serveur vers 169.254.169.254 (metadata AWS/GCP), le réseau interne, ou un
service protégé, et comme la reminder_loop relance l'URL toutes les 5 min le
SSRF devenait persistant. Le header Authorization étant aussi contrôlable,
des requêtes authentifiées vers des services internes étaient possibles.

Ajout de notifications.is_safe_ntfy_server() qui valide : schéma http(s),
absence de credentials embarqués, refus de localhost et de toutes les IP
littérales privées (loopback, link-local, 10/8, 172.16/12, 192.168/16,
fc00::/7, ::1, multicast, réservé). send_ntfy et POST /settings appellent
ce validateur avant toute ouverture de connexion ou persistance ; un serveur
refusé déclenche une redirection /settings?ssrf_error=1 côté UI.

Les tests simulant un échec réseau (127.0.0.1:1) passent sur un host public
injoignable (.invalid), et un nouveau bloc test_security couvre les cas
loopback/private/bad-scheme/credentials et la non-ouverture de socket vers
une IP privée.

💘 Generated with Crush

Assisted-by: Crush:glm-5.2
This commit is contained in:
Jacquin Antoine
2026-07-20 00:23:20 +02:00
parent 8dc2a3fa4c
commit 0329359072
4 changed files with 128 additions and 5 deletions

View File

@ -1,8 +1,10 @@
"""Reminders: nag via ntfy when present-but-unpointed or departed-but-not-depointed."""
import asyncio
import ipaddress
import logging
import urllib.request
from datetime import datetime
from urllib.parse import urlsplit
from models import list_users, load_notif_config, load_presence, load_week_pointages, append_log
@ -11,7 +13,51 @@ logger = logging.getLogger("pointeuse.notifications")
CHECK_INTERVAL_SECONDS = 300
def is_safe_ntfy_server(url: str) -> bool:
"""Valide qu'une URL ntfy est sûre (pas de SSRF vers le réseau interne/cloud metadata).
Règles :
- Schéma http(s) uniquement
- Pas de credentials embarqués dans l'URL
- Pas d'IP littérale privée (loopback, link-local, privé, multicast, réservé)
→ bloque 127.x, 10.x, 172.16/12, 192.168.x, 169.254.x (metadata AWS/GCP), fc00::/7, ::1…
- Pas de hostname localhost
"""
if not url or not isinstance(url, str):
return False
try:
parts = urlsplit(url.strip())
except ValueError:
return False
if parts.scheme not in ("http", "https"):
return False
if parts.username or parts.password:
return False
host = parts.hostname
if not host:
return False
host = host.lower().rstrip(".")
if host in ("localhost",):
return False
# IP littérale ?
try:
ip = ipaddress.ip_address(host)
except ValueError:
# hostname : on accepte (la résolution DNS n'est pas vérifiée ici, mais on a déjà
# éliminé les IP littérales dangereuses, ce qui ferme le cas le plus exploité).
return True
if ip.is_private or ip.is_loopback or ip.is_link_local or ip.is_multicast or ip.is_reserved or ip.is_unspecified:
return False
return True
def send_ntfy(server: str, topic: str, message: str, title: str, user_id: str | None = None, token: str = "") -> bool:
if not is_safe_ntfy_server(server):
logger.warning("ntfy serveur refusé (SSRF): server=%r topic=%r title=%r", server, topic, title)
if user_id:
append_log(user_id, "notif", "échec",
f"{title}{message} (serveur interdit: {server})")
return False
headers = {"Title": title.encode("utf-8"), "Priority": "high"}
if token:
headers["Authorization"] = f"Bearer {token}"