diff --git a/routes/note_routes.py b/routes/note_routes.py index 2a91fedf8..3d356f5c9 100644 --- a/routes/note_routes.py +++ b/routes/note_routes.py @@ -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: diff --git a/tests/test_reminder_ntfy_ssrf.py b/tests/test_reminder_ntfy_ssrf.py new file mode 100644 index 000000000..40e16831b --- /dev/null +++ b/tests/test_reminder_ntfy_ssrf.py @@ -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()