diff --git a/.env.example b/.env.example index 755d798..7e0168d 100644 --- a/.env.example +++ b/.env.example @@ -39,3 +39,8 @@ TRENDS_ROLLUP_LOOKBACK_HOURS=6 # In-memory per process — behind a load balancer, rate-limit at the proxy too. RATE_LIMIT_LOGIN_PER_MINUTE=10 RATE_LIMIT_INGEST_PER_MINUTE=600 + +# SSRF egress guard. Off by default (monitoring legitimately reaches internal hosts). +# Enable for multi-tenant deployments to block probes/webhooks aimed at loopback / +# RFC1918 / link-local (incl. cloud metadata 169.254.169.254) / reserved addresses. +SSRF_BLOCK_PRIVATE=false diff --git a/app/core/config.py b/app/core/config.py index 9fc8541..cbe2053 100644 --- a/app/core/config.py +++ b/app/core/config.py @@ -58,6 +58,11 @@ class Settings(BaseSettings): rate_limit_login_per_minute: int = 10 rate_limit_ingest_per_minute: int = 600 + # SSRF egress guard. Off by default (monitoring legitimately reaches internal + # hosts); enable in multi-tenant deployments to block probes/webhooks aimed at + # loopback / RFC1918 / link-local (incl. cloud metadata) / reserved addresses. + ssrf_block_private: bool = False + @property def cookie_secure(self) -> bool: """Mark session cookies `Secure` everywhere except local development, so a diff --git a/app/core/security/ssrf.py b/app/core/security/ssrf.py new file mode 100644 index 0000000..436382b --- /dev/null +++ b/app/core/security/ssrf.py @@ -0,0 +1,60 @@ +"""Optional SSRF egress guard. + +A monitoring tool legitimately reaches internal hosts, so this is OFF by default +(`SSRF_BLOCK_PRIVATE=false`). Turn it on for multi-tenant deployments to stop an +authenticated user pointing a probe or webhook at loopback, RFC1918, link-local +(incl. the cloud metadata endpoint 169.254.169.254) or other reserved ranges. + +DNS is resolved so a hostname that maps to an internal IP is caught too. +""" + +from __future__ import annotations + +import asyncio +import ipaddress +import socket + +from app.core.config import get_settings + + +class BlockedTargetError(Exception): + """Raised when egress policy forbids reaching a target host.""" + + +def _is_internal_ip(ip: ipaddress.IPv4Address | ipaddress.IPv6Address) -> bool: + return ( + ip.is_private + or ip.is_loopback + or ip.is_link_local + or ip.is_reserved + or ip.is_multicast + or ip.is_unspecified + ) + + +async def host_is_blocked(host: str) -> bool: + """True if `host` is (or resolves to) an internal/reserved address.""" + try: + return _is_internal_ip(ipaddress.ip_address(host)) # literal IP + except ValueError: + pass + try: + infos = await asyncio.to_thread(socket.getaddrinfo, host, None) + except (OSError, UnicodeError): + return False # can't resolve → the connection will simply fail on its own + for info in infos: + try: + if _is_internal_ip(ipaddress.ip_address(info[4][0])): + return True + except ValueError: + continue + return False + + +async def assert_target_allowed(host: str) -> None: + """Raise `BlockedTargetError` when the egress guard is enabled and `host` is + internal. A no-op when the guard is disabled (the default).""" + if not host: + return + if get_settings().ssrf_block_private and await host_is_blocked(host): + raise BlockedTargetError(host) diff --git a/app/tasks/item_poller.py b/app/tasks/item_poller.py index 26a7e09..a52a831 100644 --- a/app/tasks/item_poller.py +++ b/app/tasks/item_poller.py @@ -22,6 +22,7 @@ from app.core.models.metric_value import MetricValue from app.core.observability import count_ingested from app.core.security.field_crypto import decrypt_secret +from app.core.security.ssrf import BlockedTargetError, assert_target_allowed from app.core.services.trigger_service import TriggerService from app.tasks.notifications.dispatcher import schedule_item_trigger_alerts from app.tasks.probes import SnmpError, _snmp_get @@ -95,6 +96,11 @@ async def _poll_one(target: _PollTarget, now: datetime) -> None: if not oid: logger.warning("snmp item %s has no 'oid' in config; skipping", target.item_id) return + try: + await assert_target_allowed(target.address) + except BlockedTargetError: + logger.debug("snmp item %s target blocked by egress policy", target.item_id) + return community = decrypt_secret(str(target.config.get("community", "public"))) port = int(target.config.get("port", 161)) try: diff --git a/app/tasks/notifications/delivery.py b/app/tasks/notifications/delivery.py index 7f3ffe3..fa94430 100644 --- a/app/tasks/notifications/delivery.py +++ b/app/tasks/notifications/delivery.py @@ -11,6 +11,7 @@ import httpx from app.core.config import Settings +from app.core.security.ssrf import BlockedTargetError, assert_target_allowed logger = logging.getLogger(__name__) @@ -52,6 +53,11 @@ async def send_email(settings: Settings, to: str, subject: str, body: str) -> No async def send_webhook(url: str, payload: dict[str, Any], secret: str | None = None) -> None: + try: + await assert_target_allowed(httpx.URL(url).host) + except BlockedTargetError as exc: + raise DeliveryError(f"webhook target blocked by egress policy: {exc}") from exc + body = json.dumps(payload, separators=(",", ":")).encode("utf-8") headers = { "Content-Type": "application/json", diff --git a/app/tasks/probes.py b/app/tasks/probes.py index 3e1cc9f..3eac910 100644 --- a/app/tasks/probes.py +++ b/app/tasks/probes.py @@ -19,10 +19,23 @@ get_cmd, ) +from app.core.config import get_settings from app.core.models.monitor import Monitor, MonitorType from app.core.models.monitor_result import ProbeStatus +from app.core.security.ssrf import BlockedTargetError, assert_target_allowed DEFAULT_TIMEOUT_SECONDS = 10.0 + + +async def _egress_guard(host: str | None) -> ProbeOutcome | None: + """`None` if the target is allowed, else a DOWN outcome (egress policy).""" + try: + await assert_target_allowed(host or "") + except BlockedTargetError: + return ProbeOutcome(ProbeStatus.DOWN, None, "target blocked by egress policy") + return None + + SSL_EXPIRY_WARNING_DAYS = 14 DEFAULT_SNMP_COMMUNITY = "public" DEFAULT_SNMP_OID = "1.3.6.1.2.1.1.3.0" # sysUpTime @@ -57,11 +70,16 @@ async def run_probe(monitor: Monitor) -> ProbeOutcome: async def _probe_http(url: str) -> ProbeOutcome: + if (blocked := await _egress_guard(httpx.URL(url).host)) is not None: + return blocked + # When the egress guard is on, don't auto-follow redirects either — a 30x could + # otherwise bounce the request to an internal address the guard just rejected. + follow_redirects = not get_settings().ssrf_block_private start = time.perf_counter() try: async with httpx.AsyncClient( timeout=DEFAULT_TIMEOUT_SECONDS, - follow_redirects=True, + follow_redirects=follow_redirects, headers={"User-Agent": "GhostMonitor/0.1"}, ) as client: response = await client.get(url) @@ -88,6 +106,8 @@ async def _probe_tcp(url: str) -> ProbeOutcome: None, f"invalid tcp target: {url!r} (expected host:port or tcp://host:port)", ) + if (blocked := await _egress_guard(host)) is not None: + return blocked start = time.perf_counter() try: async with asyncio.timeout(DEFAULT_TIMEOUT_SECONDS): @@ -114,6 +134,8 @@ async def _probe_ssl(url: str) -> ProbeOutcome: None, f"invalid ssl target: {url!r} (expected host, host:port or https://host[:port])", ) + if (blocked := await _egress_guard(host)) is not None: + return blocked context = ssl.create_default_context() start = time.perf_counter() try: @@ -166,6 +188,8 @@ async def _probe_ping(url: str) -> ProbeOutcome: target = _parse_ping_target(url) if not target: return ProbeOutcome(ProbeStatus.DOWN, None, f"invalid ping target: {url!r}") + if (blocked := await _egress_guard(target)) is not None: + return blocked try: proc = await asyncio.create_subprocess_exec( @@ -234,6 +258,8 @@ async def _probe_snmp(url: str) -> ProbeOutcome: f"invalid snmp target: {url!r} (expected snmp://[community@]host[:port]/OID)", ) host, port, community, oid = target + if (blocked := await _egress_guard(host)) is not None: + return blocked start = time.perf_counter() try: async with asyncio.timeout(DEFAULT_TIMEOUT_SECONDS + 2): diff --git a/tests/test_ssrf.py b/tests/test_ssrf.py new file mode 100644 index 0000000..c1bf829 --- /dev/null +++ b/tests/test_ssrf.py @@ -0,0 +1,55 @@ +"""Optional SSRF egress guard for probes and webhooks.""" + +from __future__ import annotations + +from ipaddress import ip_address + +import pytest + +from app.core.config import get_settings +from app.core.models.monitor_result import ProbeStatus +from app.core.security import ssrf + + +def test_internal_ip_classification() -> None: + for internal in ("127.0.0.1", "10.0.0.1", "192.168.1.1", "169.254.169.254", "::1"): + assert ssrf._is_internal_ip(ip_address(internal)), internal + assert not ssrf._is_internal_ip(ip_address("8.8.8.8")) + + +async def test_host_is_blocked_for_ip_literals() -> None: + assert await ssrf.host_is_blocked("169.254.169.254") is True # cloud metadata + assert await ssrf.host_is_blocked("10.0.0.5") is True + assert await ssrf.host_is_blocked("192.168.1.1") is True + assert await ssrf.host_is_blocked("::1") is True + assert await ssrf.host_is_blocked("1.1.1.1") is False + + +async def test_assert_target_allowed_respects_toggle(monkeypatch: pytest.MonkeyPatch) -> None: + # Off by default: even internal targets pass (monitoring reaches internal hosts). + await ssrf.assert_target_allowed("127.0.0.1") + + enabled = get_settings().model_copy(update={"ssrf_block_private": True}) + monkeypatch.setattr(ssrf, "get_settings", lambda: enabled) + with pytest.raises(ssrf.BlockedTargetError): + await ssrf.assert_target_allowed("169.254.169.254") + await ssrf.assert_target_allowed("8.8.8.8") # public still allowed + + +async def test_http_probe_blocked_when_guard_enabled(monkeypatch: pytest.MonkeyPatch) -> None: + from app.tasks import probes + + enabled = get_settings().model_copy(update={"ssrf_block_private": True}) + monkeypatch.setattr(ssrf, "get_settings", lambda: enabled) + outcome = await probes._probe_http("http://127.0.0.1:9/") + assert outcome.status is ProbeStatus.DOWN + assert "blocked" in (outcome.error or "") + + +async def test_webhook_blocked_when_guard_enabled(monkeypatch: pytest.MonkeyPatch) -> None: + from app.tasks.notifications.delivery import DeliveryError, send_webhook + + enabled = get_settings().model_copy(update={"ssrf_block_private": True}) + monkeypatch.setattr(ssrf, "get_settings", lambda: enabled) + with pytest.raises(DeliveryError, match="blocked"): + await send_webhook("http://10.0.0.1/hook", {"x": 1})