Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
5 changes: 5 additions & 0 deletions app/core/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
60 changes: 60 additions & 0 deletions app/core/security/ssrf.py
Original file line number Diff line number Diff line change
@@ -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)
6 changes: 6 additions & 0 deletions app/tasks/item_poller.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down
6 changes: 6 additions & 0 deletions app/tasks/notifications/delivery.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__)

Expand Down Expand Up @@ -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",
Expand Down
28 changes: 27 additions & 1 deletion app/tasks/probes.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand All @@ -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):
Expand All @@ -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:
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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):
Expand Down
55 changes: 55 additions & 0 deletions tests/test_ssrf.py
Original file line number Diff line number Diff line change
@@ -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})
Loading