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