mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-09-29 18:51:43 +02:00
141 lines
6.2 KiB
Python
141 lines
6.2 KiB
Python
"""Shared URL-safety primitives for the SSRF guards in this package.
|
|
|
|
Bambuddy has exactly two outbound-URL policies, and which one applies is a
|
|
property of the *service*, not of the caller:
|
|
|
|
- **LAN-service** (``assert_safe_lan_service_url`` below) — the service
|
|
legitimately lives on the same host or home LAN, so loopback and RFC-1918
|
|
must be permitted; blocking them would break the normal topology. Used for
|
|
Spoolman, self-hosted notification servers (ntfy, Bark, Gotify, custom
|
|
webhooks), Home Assistant, the Obico ML endpoint and the slicer sidecars.
|
|
- **Public-internet** (``_oidc_helpers.assert_safe_public_https_url``) — the
|
|
resource can only sensibly live on the public internet, so a private
|
|
address is an SSRF probe rather than a configuration. Used for OIDC issuer
|
|
and icon URLs.
|
|
|
|
Both reject the cases that are dangerous regardless of topology: non-HTTP
|
|
schemes, numeric-encoded IPs, cloud-metadata endpoints, multicast and
|
|
unspecified addresses, and IPv4-mapped IPv6 encodings of any of the above.
|
|
|
|
The LAN-service policy lives here because it now has several callers; the
|
|
public-internet policy stays in ``_oidc_helpers`` next to its only consumer.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import ipaddress
|
|
import re
|
|
from urllib.parse import urlparse
|
|
|
|
# Cloud-provider metadata endpoints — the classic SSRF credential-exfil
|
|
# targets. Both guards reject these unconditionally.
|
|
CLOUD_METADATA_IPS = frozenset(
|
|
{
|
|
# AWS / GCP / Azure / Oracle / DigitalOcean IMDS
|
|
ipaddress.ip_address("169.254.169.254"),
|
|
# Alibaba Cloud metadata
|
|
ipaddress.ip_address("100.100.100.200"),
|
|
# AWS IMDS IPv6
|
|
ipaddress.ip_address("fd00:ec2::254"),
|
|
}
|
|
)
|
|
|
|
# The DNS-name form of the same targets. Neither guard resolves hostnames (see
|
|
# the TOCTOU note on each), so an IP blocklist alone cannot catch these — but a
|
|
# literal-string match needs no resolution and costs nothing. These names only
|
|
# resolve inside the respective cloud, so there is no legitimate reason for any
|
|
# Bambuddy integration to point at one.
|
|
CLOUD_METADATA_HOSTNAMES = frozenset(
|
|
{
|
|
"metadata.google.internal", # GCP
|
|
"metadata.goog", # GCP short form
|
|
}
|
|
)
|
|
|
|
|
|
# libc and browsers parse numeric-encoded IP forms (decimal ``2130706433``
|
|
# for 127.0.0.1, hex ``0x7f000001``) but Python's ``ipaddress.ip_address``
|
|
# raises ValueError on these, so they slip past the IP-class checks if
|
|
# not caught first. Used by both guards to reject up-front.
|
|
NUMERIC_IP_RE = re.compile(r"^(0x[0-9a-f]+|[0-9]+)$", re.I)
|
|
|
|
|
|
def unwrap_ipv4_mapped(
|
|
addr: ipaddress.IPv4Address | ipaddress.IPv6Address,
|
|
) -> ipaddress.IPv4Address | ipaddress.IPv6Address:
|
|
"""Return the underlying IPv4 for an IPv4-mapped IPv6 address, else return *addr*.
|
|
|
|
``::ffff:127.0.0.1`` and similar mapped forms must be unwrapped before
|
|
the per-class checks (``is_private``, ``is_loopback``, …) — otherwise
|
|
an attacker can encode a blocked IPv4 address as an IPv6 literal to
|
|
bypass the guard.
|
|
"""
|
|
if isinstance(addr, ipaddress.IPv6Address) and addr.ipv4_mapped is not None:
|
|
return addr.ipv4_mapped
|
|
return addr
|
|
|
|
|
|
def assert_safe_lan_service_url(url: str, *, label: str) -> None:
|
|
"""Raise ValueError if *url* is unsafe for a service that may live on the LAN.
|
|
|
|
``label`` names the setting in the error message ("Spoolman URL", "ntfy
|
|
server URL", …) so the user sees which field they need to correct.
|
|
|
|
Loopback (127.0.0.1) and RFC-1918 private ranges are deliberately
|
|
**permitted** — Bambuddy is self-hosted and running Spoolman, ntfy,
|
|
Bark, Home Assistant, an Obico ML endpoint or a slicer sidecar on the
|
|
same host or home LAN is THE normal topology, not an attack. A blanket
|
|
private-address block would break those integrations for most installs.
|
|
|
|
What is rejected is dangerous under any topology:
|
|
|
|
- Schemes other than http/https. ``httpx`` already raises
|
|
``UnsupportedProtocol`` for ``file://``/``gopher://`` etc., so this is
|
|
about returning a clear validation error at configuration time rather
|
|
than an opaque failure at delivery time.
|
|
- Numeric-encoded IPv4 (decimal ``2130706433``, hex ``0x7f000001``) —
|
|
libc and browsers resolve these, but Python's ``ipaddress`` raises
|
|
ValueError on them, so they would slip past the checks below.
|
|
- Cloud-provider metadata endpoints — the high-value SSRF target, and
|
|
never a legitimate destination for any of these services.
|
|
- Multicast and unspecified addresses — pointless as a destination and
|
|
indicative of misuse.
|
|
- IPv4-mapped IPv6 encodings of any of the above.
|
|
|
|
Symbolic hostnames are otherwise accepted without DNS resolution, matching
|
|
the public-internet guard: resolution here would be both a TOCTOU (DNS can
|
|
change between validation and request) and a request the validator
|
|
shouldn't be making. The one exception is the fixed set of cloud-metadata
|
|
hostnames, which is a literal-string match and needs no resolution.
|
|
"""
|
|
parsed = urlparse(url)
|
|
if parsed.scheme.lower() not in ("http", "https"):
|
|
raise ValueError(f"{label} must use http or https")
|
|
|
|
hostname = (parsed.hostname or "").lower()
|
|
|
|
# "http:///path" parses to an empty hostname. Never a valid destination,
|
|
# and without this it falls through the ip_address() ValueError branch
|
|
# below and is accepted as if it were a symbolic hostname.
|
|
if not hostname:
|
|
raise ValueError(f"{label} must include a hostname")
|
|
|
|
if hostname in CLOUD_METADATA_HOSTNAMES:
|
|
raise ValueError(f"{label} must not point to a cloud metadata endpoint")
|
|
|
|
if NUMERIC_IP_RE.match(hostname):
|
|
raise ValueError(f"{label} must not use numeric-encoded IP addresses; use standard dotted-decimal notation")
|
|
|
|
try:
|
|
addr = ipaddress.ip_address(hostname)
|
|
except ValueError:
|
|
return # symbolic hostname — out of scope by design (no DNS check)
|
|
|
|
effective = unwrap_ipv4_mapped(addr)
|
|
|
|
if effective in CLOUD_METADATA_IPS:
|
|
raise ValueError(f"{label} must not point to a cloud metadata endpoint")
|
|
|
|
if effective.is_multicast or effective.is_unspecified:
|
|
raise ValueError(f"{label} must not point to a multicast or unspecified address")
|