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

@ -27,7 +27,7 @@ from models import (
from calcul import compute_week, minutes_to_hhmm, hhmm_to_minutes, heures_dues
from stats import compute_all_stats
from export import export_semaine_ods, export_annee_ods
from notifications import reminder_loop, send_ntfy
from notifications import reminder_loop, send_ntfy, is_safe_ntfy_server
logging.basicConfig(
level=getattr(logging, load_config().get("log_level", "DEBUG").upper(), logging.DEBUG),
@ -624,6 +624,11 @@ def settings_save(
rappel_debut_h = max(0, min(23, rappel_debut_h))
rappel_fin_h = max(rappel_debut_h + 1, min(24, rappel_fin_h))
ntfy_server = ntfy_server.strip()
if ntfy_server and not is_safe_ntfy_server(ntfy_server):
logger.warning("réglages notif rejetés (SSRF): user_id=%r ntfy_server=%r", user_id, ntfy_server)
return RedirectResponse("/settings?ssrf_error=1", status_code=303)
logger.info(
"réglages notif mis à jour: user_id=%r ntfy_topic=%r ntfy_server=%r plage=%sh-%sh token=%s",
user_id, ntfy_topic, ntfy_server, rappel_debut_h, rappel_fin_h,

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}"

View File

@ -93,10 +93,10 @@ def test_aide_page_content():
# ── Logging via notifications.send_ntfy (échec) ─────────────────────────────
def test_send_ntfy_failure_logs_entry():
# Serveur injoignable → échec → entrée de log "échec"
# Serveur injoignable (host public sans résolution) → échec → entrée de log "échec"
import notifications
notifications.send_ntfy(
"http://127.0.0.1:1", "topic-injoignable",
"https://ntfy-injoignable.invalid", "topic-injoignable",
"msg test", "titre test", user_id="alice",
)
logs = models.load_logs("alice")
@ -162,7 +162,7 @@ def test_test_ntfy_without_topic_returns_error():
def test_test_ntfy_with_bad_server_returns_failure():
# Topic renseigné mais serveur injoignable → échec
models.save_notif_config("alice", ntfy_topic="topic-test", ntfy_server="http://127.0.0.1:1")
models.save_notif_config("alice", ntfy_topic="topic-test", ntfy_server="https://ntfy-injoignable.invalid")
r = client.post("/settings/test-ntfy", cookies=COOKIES)
assert r.status_code == 200
assert "settings-error" in r.text
@ -174,7 +174,7 @@ def test_test_ntfy_with_bad_server_returns_failure():
def test_test_ntfy_logs_to_journal():
"""Le bouton de test doit écrire dans le journal (même en échec)."""
models.save_notif_config("alice", ntfy_topic="topic-test-2", ntfy_server="http://127.0.0.1:1")
models.save_notif_config("alice", ntfy_topic="topic-test-2", ntfy_server="https://ntfy-injoignable.invalid")
client.post("/settings/test-ntfy", cookies=COOKIES)
logs = models.load_logs("alice")
# L'entrée la plus récente doit être l'échec du test (message contient "Test pointeuse")

View File

@ -90,3 +90,75 @@ def test_secret_is_stable_across_calls():
s1 = models.load_or_create_secret()
s2 = models.load_or_create_secret()
assert s1 == s2
# ── Validation SSRF du serveur ntfy ──────────────────────────────────────────
import notifications # noqa: E402
def test_ntfy_server_validation_accepts_https():
assert notifications.is_safe_ntfy_server("https://ntfy.sh")
assert notifications.is_safe_ntfy_server("https://ntfy.example.com:8080")
def test_ntfy_server_validation_rejects_loopback():
for bad in ("http://127.0.0.1:1", "http://127.0.0.1", "http://localhost",
"https://localhost:8443", "https://[::1]"):
assert not notifications.is_safe_ntfy_server(bad), bad
def test_ntfy_server_validation_rejects_private_networks():
for bad in ("https://10.0.0.1", "https://172.16.5.5", "https://192.168.1.1",
"https://169.254.169.254", "http://10.255.255.1:8080"):
assert not notifications.is_safe_ntfy_server(bad), bad
def test_ntfy_server_validation_rejects_bad_schemes():
for bad in ("ftp://ntfy.sh", "file:///etc/passwd", "gopher://x", "javascript:alert(1)"):
assert not notifications.is_safe_ntfy_server(bad), bad
def test_ntfy_server_validation_rejects_credentials_in_url():
assert not notifications.is_safe_ntfy_server("https://user:pass@ntfy.sh")
def test_ntfy_server_validation_rejects_empty_or_none():
for bad in ("", None, " "):
assert not notifications.is_safe_ntfy_server(bad), bad
def test_send_ntfy_refuses_ssrf_target():
"""Un appel vers une IP privée ne doit jamais ouvrir de connexion réseau."""
import urllib.request as u
called = {"n": 0}
orig = u.urlopen
try:
u.urlopen = lambda *a, **kw: called.__setitem__("n", called["n"] + 1)
ok = notifications.send_ntfy(
"http://169.254.169.254", "ssrf-topic",
"msg", "titre", user_id=None, token="",
)
finally:
u.urlopen = orig
assert ok is False
assert called["n"] == 0 # urlopen n'a jamais été appelé
def test_settings_save_rejects_ssrf_ntfy_server():
"""POST /settings avec un serveur SSRF doit être rejeté."""
cookies = _login("carol")
# Préserve un topic valide
models.save_notif_config("carol", ntfy_topic="t", ntfy_server="https://ntfy.sh")
r = client.post("/settings", cookies=cookies, data={
"ntfy_topic": "topic-x",
"ntfy_server": "http://169.254.169.254",
"ntfy_token": "",
"rappel_debut_h": "8",
"rappel_fin_h": "19",
}, follow_redirects=False)
assert r.status_code == 303
assert "ssrf_error=1" in r.headers["location"]
# La valeur précédente est conservée
cfg = models.load_notif_config("carol")
assert cfg["ntfy_server"] == "https://ntfy.sh"