fix(security): apply the webhook SSRF guard to the reminder ntfy sender (#5142)

The webhook branch of dispatch_reminder validates its target with
check_outbound_url before posting; the ntfy branch posted to the
integration's user-configured base_url with no check, so a base_url
pointing at the metadata range (169.254.169.254) was fetched
server-side — with the integration's Authorization header attached —
every time a reminder fired.

Run the same check (and honor the same REMINDER_WEBHOOK_BLOCK_PRIVATE_IPS
knob) before the post, surfacing rejections in ntfy_error exactly like
the webhook branch does. LAN ntfy servers keep working by default,
matching the project's local-first policy.

Fixes #5141

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Wes Huber
2026-07-04 09:05:37 -07:00
committed by GitHub
parent 6114ef0d6d
commit 897e6950af
2 changed files with 133 additions and 5 deletions
+16 -5
View File
@@ -483,11 +483,22 @@ async def dispatch_reminder(
api_key = intg.get("api_key", "")
if api_key:
hdrs["Authorization"] = f"Bearer {api_key}"
async with httpx.AsyncClient(timeout=10.0) as client:
resp = await client.post(f"{base}/{topic}", content=ntfy_body, headers=hdrs)
ntfy_sent = resp.is_success
if not ntfy_sent:
ntfy_error = f"ntfy returned HTTP {resp.status_code}"
# SSRF guard — same check (and env knob) as the webhook branch
# above: link-local / metadata addresses are always rejected;
# REMINDER_WEBHOOK_BLOCK_PRIVATE_IPS=true also blocks RFC-1918
# so a ntfy base_url can't be pointed at internal services.
import os as _os
from src.url_safety import check_outbound_url as _chk
_block = _os.getenv("REMINDER_WEBHOOK_BLOCK_PRIVATE_IPS", "false").lower() == "true"
_ok, _reason = _chk(f"{base}/{topic}", block_private=_block)
if not _ok:
ntfy_error = f"ntfy URL rejected: {_reason}"
else:
async with httpx.AsyncClient(timeout=10.0) as client:
resp = await client.post(f"{base}/{topic}", content=ntfy_body, headers=hdrs)
ntfy_sent = resp.is_success
if not ntfy_sent:
ntfy_error = f"ntfy returned HTTP {resp.status_code}"
else:
ntfy_error = "No enabled ntfy integration"
except Exception as e:
+117
View File
@@ -0,0 +1,117 @@
"""Regression: the reminder ntfy sender must run the same SSRF guard as the
webhook sender.
The webhook branch of dispatch_reminder validates its target with
src.url_safety.check_outbound_url before posting; the ntfy branch posted to
the integration's base_url with no check, so a base_url pointing at the cloud
metadata range (169.254.169.254) was fetched server-side — with the
integration's Authorization header attached — every time a reminder fired.
"""
import asyncio
from unittest.mock import MagicMock, patch
import httpx
from routes.note_routes import dispatch_reminder
def _ntfy_integration(base_url):
return [{
"preset": "ntfy",
"enabled": True,
"base_url": base_url,
"api_key": "secret-token",
"name": "ntfy",
}]
def _settings(**extra):
return {
"reminder_channel": "ntfy",
"reminder_llm_synthesis": False,
"reminder_ntfy_topic": "reminders",
**extra,
}
class _SpyAsyncClient:
"""Stands in for httpx.AsyncClient; records posts, returns success."""
calls = []
def __init__(self, **kwargs):
pass
async def __aenter__(self):
return self
async def __aexit__(self, *a):
pass
async def post(self, url, **kw):
_SpyAsyncClient.calls.append(url)
resp = MagicMock()
resp.is_success = True
resp.status_code = 200
return resp
def _dispatch():
return asyncio.run(dispatch_reminder(
"Title", "Body", note_id="", queue_browser=True,
settings_override=_settings(),
))
def test_metadata_ip_ntfy_base_url_is_rejected_and_not_fetched():
_SpyAsyncClient.calls = []
with (
patch("src.integrations.load_integrations",
return_value=_ntfy_integration("http://169.254.169.254")),
patch.object(httpx, "AsyncClient", _SpyAsyncClient),
):
result = _dispatch()
assert _SpyAsyncClient.calls == [], "metadata address must never be fetched"
assert result["ntfy_sent"] is False
assert "rejected" in result["ntfy_error"].lower()
def test_public_ntfy_base_url_still_sends():
_SpyAsyncClient.calls = []
with (
# 93.184.216.34 is a public literal — no DNS resolution involved.
patch("src.integrations.load_integrations",
return_value=_ntfy_integration("http://93.184.216.34")),
patch.object(httpx, "AsyncClient", _SpyAsyncClient),
):
result = _dispatch()
assert _SpyAsyncClient.calls == ["http://93.184.216.34/reminders"]
assert result["ntfy_sent"] is True
assert result["ntfy_error"] == ""
def test_private_ntfy_base_url_blocked_only_with_env_knob(monkeypatch):
# Default (local-first): a LAN ntfy server is a normal setup and must work.
_SpyAsyncClient.calls = []
monkeypatch.delenv("REMINDER_WEBHOOK_BLOCK_PRIVATE_IPS", raising=False)
with (
patch("src.integrations.load_integrations",
return_value=_ntfy_integration("http://192.168.1.50")),
patch.object(httpx, "AsyncClient", _SpyAsyncClient),
):
result = _dispatch()
assert result["ntfy_sent"] is True
# Locked-down deployments: the same knob the webhook branch honors.
_SpyAsyncClient.calls = []
monkeypatch.setenv("REMINDER_WEBHOOK_BLOCK_PRIVATE_IPS", "true")
with (
patch("src.integrations.load_integrations",
return_value=_ntfy_integration("http://192.168.1.50")),
patch.object(httpx, "AsyncClient", _SpyAsyncClient),
):
result = _dispatch()
assert _SpyAsyncClient.calls == []
assert result["ntfy_sent"] is False
assert "rejected" in result["ntfy_error"].lower()