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

@ -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")