mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-07-15 12:58:04 +00:00
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:
@@ -483,6 +483,17 @@ async def dispatch_reminder(
|
|||||||
api_key = intg.get("api_key", "")
|
api_key = intg.get("api_key", "")
|
||||||
if api_key:
|
if api_key:
|
||||||
hdrs["Authorization"] = f"Bearer {api_key}"
|
hdrs["Authorization"] = f"Bearer {api_key}"
|
||||||
|
# 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:
|
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||||
resp = await client.post(f"{base}/{topic}", content=ntfy_body, headers=hdrs)
|
resp = await client.post(f"{base}/{topic}", content=ntfy_body, headers=hdrs)
|
||||||
ntfy_sent = resp.is_success
|
ntfy_sent = resp.is_success
|
||||||
|
|||||||
@@ -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()
|
||||||
Reference in New Issue
Block a user