Security hardening (maziggy/bambuddy-security #21)

This commit is contained in:
maziggy
2026-10-14 13:21:25 +02:00
parent 047fe0df49
commit b6847f246e
7 changed files with 169 additions and 47 deletions
-24
View File
@@ -2745,30 +2745,6 @@
}
],
"./backend/app/services/external_camera.py": [
{
"code": "reportOptionalMemberAccess",
"range": {
"startColumn": 10,
"endColumn": 21,
"lineCount": 1
}
},
{
"code": "reportOptionalMemberAccess",
"range": {
"startColumn": 10,
"endColumn": 24,
"lineCount": 1
}
},
{
"code": "reportOptionalMemberAccess",
"range": {
"startColumn": 10,
"endColumn": 23,
"lineCount": 1
}
},
{
"code": "reportOptionalSubscript",
"range": {
+9 -3
View File
@@ -16,8 +16,9 @@ from urllib.parse import urlparse
from backend.app.api.routes._url_safety import (
CLOUD_METADATA_HOSTNAMES,
CLOUD_METADATA_IPS,
NUMERIC_IP_RE,
is_numeric_encoded_ip,
unwrap_ipv4_mapped,
url_hostname,
)
@@ -54,17 +55,22 @@ def assert_safe_public_https_url(url: str) -> None:
if parsed.scheme.lower() != "https":
raise ValueError("icon URL must use https://")
hostname = (parsed.hostname or "").lower()
hostname = url_hostname(parsed)
# "https:///path" parses to an empty hostname; without this it reaches the
# ip_address() ValueError branch and is accepted as a symbolic hostname.
if not hostname:
raise ValueError("icon URL must include a hostname")
# HTTP clients map other dot characters and fullwidth forms to ASCII
# before they connect: a host the checks below cannot read is refused.
if not hostname.isascii():
raise ValueError("icon URL must use an ASCII host name (punycode, xn--..., for international names)")
if hostname in CLOUD_METADATA_HOSTNAMES:
raise ValueError("icon URL must not point to a cloud metadata endpoint")
if NUMERIC_IP_RE.match(hostname):
if is_numeric_encoded_ip(hostname):
raise ValueError("icon URL must not use numeric-encoded IP addresses")
try:
+43 -9
View File
@@ -25,7 +25,7 @@ from __future__ import annotations
import ipaddress
import re
from urllib.parse import urlparse
from urllib.parse import ParseResult, urlparse
# Cloud-provider metadata endpoints — the classic SSRF credential-exfil
# targets. Both guards reject these unconditionally.
@@ -53,17 +53,41 @@ CLOUD_METADATA_HOSTNAMES = frozenset(
)
# 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)
# libc and browsers parse numeric-encoded IP forms (decimal ``2130706433`` for
# 127.0.0.1, hex ``0x7f000001``, dotted hex or octal, short forms such as
# ``127.1``) but Python's ``ipaddress`` raises ValueError on them, so they slip
# past the IP-class checks if not caught first. Used by both guards to reject
# them up front, see ``is_numeric_encoded_ip``. One label of such an address
# is decimal, octal or hex.
_NUMERIC_LABEL_RE = re.compile(r"^(0x[0-9a-f]*|[0-9]+)$", re.I)
def url_hostname(parsed: ParseResult) -> str:
"""The URL's hostname as resolvers see it: lower case, no trailing dot."""
return (parsed.hostname or "").lower().rstrip(".")
def is_numeric_encoded_ip(hostname: str) -> bool:
"""Whether *hostname* is an IPv4 address in a notation other than plain
dotted decimal: one number, dotted hex or octal, a leading zero, or fewer
than four parts. Resolvers read all of them as an address; the checks on
addresses only see standard notation.
"""
labels = hostname.split(".")
if not all(_NUMERIC_LABEL_RE.match(label) for label in labels):
return False
try:
ipaddress.IPv4Address(hostname)
except ValueError:
return True
return False
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*.
"""Return the underlying IPv4 for an IPv4-mapped IPv6 address, *addr* without
its zone id, else *addr*.
``::ffff:127.0.0.1`` and similar mapped forms must be unwrapped before
the per-class checks (``is_private``, ``is_loopback``, …) — otherwise
@@ -72,6 +96,10 @@ def unwrap_ipv4_mapped(
"""
if isinstance(addr, ipaddress.IPv6Address) and addr.ipv4_mapped is not None:
return addr.ipv4_mapped
# A zone id is ignored on connect to an address that is not link-local,
# but it makes the address compare unequal to the same one without it.
if isinstance(addr, ipaddress.IPv6Address) and addr.scope_id is not None:
return ipaddress.IPv6Address(str(addr).split("%", 1)[0])
return addr
@@ -112,7 +140,7 @@ def assert_safe_lan_service_url(url: str, *, label: str) -> None:
if parsed.scheme.lower() not in ("http", "https"):
raise ValueError(f"{label} must use http or https")
hostname = (parsed.hostname or "").lower()
hostname = url_hostname(parsed)
# "http:///path" parses to an empty hostname. Never a valid destination,
# and without this it falls through the ip_address() ValueError branch
@@ -120,10 +148,16 @@ def assert_safe_lan_service_url(url: str, *, label: str) -> None:
if not hostname:
raise ValueError(f"{label} must include a hostname")
# HTTP clients map other dot characters, fullwidth digits and similar
# forms to plain ASCII before they connect, so a host the checks below
# cannot read is refused rather than let through as a name.
if not hostname.isascii():
raise ValueError(f"{label} must use an ASCII host name (punycode, xn--..., for international names)")
if hostname in CLOUD_METADATA_HOSTNAMES:
raise ValueError(f"{label} must not point to a cloud metadata endpoint")
if NUMERIC_IP_RE.match(hostname):
if is_numeric_encoded_ip(hostname):
raise ValueError(f"{label} must not use numeric-encoded IP addresses; use standard dotted-decimal notation")
try:
+16 -6
View File
@@ -54,7 +54,16 @@ def _blocked_host_reason(hostname: str) -> str | None:
accept them, so refusing to understand them here would only mean not seeing
where the request is actually going.
"""
host = hostname.lower()
from backend.app.api.routes._url_safety import CLOUD_METADATA_HOSTNAMES, CLOUD_METADATA_IPS, unwrap_ipv4_mapped
# A trailing dot names the same host to every resolver.
host = hostname.lower().rstrip(".")
# The HTTP clients map other dot characters, fullwidth digits and similar
# forms to plain ASCII before they connect: a host not written in ASCII
# cannot be judged here, so it is refused.
if not host.isascii():
return "a host name not written in ASCII (use punycode, xn--...)"
ip: ipaddress.IPv4Address | ipaddress.IPv6Address | None = None
try:
@@ -73,15 +82,16 @@ def _blocked_host_reason(hostname: str) -> str | None:
# intermittent local DNS.
if host == "localhost" or host.endswith(".localhost"):
return "localhost"
if host in ("metadata.google.internal", "metadata.google"):
if host in CLOUD_METADATA_HOSTNAMES or host == "metadata.google":
return "a cloud metadata service"
return None
# ::ffff:127.0.0.1 is loopback wearing an IPv6 spelling.
mapped = getattr(ip, "ipv4_mapped", None)
if mapped is not None:
ip = mapped
# ::ffff:127.0.0.1 is loopback wearing an IPv6 spelling, and a zone id
# only hides the address it is attached to.
ip = unwrap_ipv4_mapped(ip)
if ip in CLOUD_METADATA_IPS:
return "a cloud metadata service"
if ip.is_loopback:
return "loopback"
if ip.is_unspecified:
@@ -264,3 +264,32 @@ class TestUsbDevicePaths:
path_cls.return_value.__str__.return_value = "/dev/video7"
assert _safe_usb_device_path("/dev/video7") == "/dev/video7"
path_cls.assert_called_once_with("/dev/video7")
class TestTheSameHostsSpelledOtherwise:
"""A trailing dot is the same host to every resolver, and the metadata
endpoints outside the link-local range are still metadata endpoints."""
@pytest.mark.parametrize(
"host",
[
"127.0.0.1.",
"169.254.169.254.",
"localhost.",
"metadata.google.internal.",
"metadata.goog",
"100.100.100.200",
"[fd00:ec2::254]",
"169\u3002254\u3002169\u3002254",
"\uff11\uff12\uff17.\uff10.\uff10.\uff11",
"\u24dcetadata.google.internal",
"[fd00:ec2::254%251]",
"[fd00:ec2::254%1]",
],
)
def test_is_refused(self, host):
assert _sanitize_camera_url(f"http://{host}/snap.jpg", HTTP_SCHEMES) is None
@pytest.mark.parametrize("host", ["192.168.1.50.", "camera.lan.", "100.64.0.9", "xn--kamera-kche-dlb.lan"])
def test_a_lan_camera_still_passes(self, host):
assert _sanitize_camera_url(f"http://{host}/snap.jpg", HTTP_SCHEMES) is not None
@@ -62,6 +62,32 @@ UNIVERSALLY_BLOCKED = [
"http://metadata.google.internal/",
"http://METADATA.GOOGLE.INTERNAL/computeMetadata/v1/",
"http://metadata.goog/",
# Classic inet_aton notations other than a single number: libc and HTTP
# clients resolve them to the same addresses.
"http://0xa9.0xfe.0xa9.0xfe/",
"http://0xA9.254.169.254/",
"http://169.254.43518/",
"http://169.16689662/",
"http://0251.0376.0251.0376/",
"http://0169.254.169.254/",
"http://0x7f.1/",
# A trailing dot is the same host.
"http://169.254.169.254./",
"http://metadata.google.internal./",
"http://0x7f000001./",
# Other dot characters, fullwidth digits and enclosed letters: HTTP clients
# map them to the plain forms before connecting.
"http://169\u3002254\u3002169\u3002254/",
"http://169\uff0e254\uff0e169\uff0e254/",
"http://169\uff61254\uff61169\uff61254/",
"http://\uff11\uff16\uff19.\uff12\uff15\uff14.\uff11\uff16\uff19.\uff12\uff15\uff14/",
"http://\u24dcetadata.google.internal/",
"http://metadata\u3002google\u3002internal/",
"http://0xa9\u30020xfe\u30020xa9\u30020xfe/",
# A zone id on an address that is not link-local is ignored on connect.
"http://[fd00:ec2::254%251]/",
"http://[fd00:ec2::254%25eth0]/",
"http://[fd00:ec2::254%1]/",
]
# The normal self-hosted topology — the LAN tier must permit all of these.
@@ -73,6 +99,12 @@ LAN_ALLOWED = [
"http://172.16.4.9:8080",
"https://ntfy.example.com/",
"http://spoolman.lan:7912",
"http://3dprinter.lan:8080",
"http://node-01:8080",
"http://192.168.1.50.:8123",
"http://[fd12:3456::1]:8123",
"http://xn--drucker-kche-dlb.lan:8080",
"http://[fe80::1%25eth0]:8123",
]
@@ -452,6 +484,7 @@ def test_ha_guard_keeps_ipv6_literals_bracketed():
"169.254.169.254",
"100.100.100.200",
"fd00:ec2::254",
"fd00:ec2::254%1",
"0.0.0.0", # nosec B104 — rejection fixture, not a bind address: the assertion below is that the guard refuses it
"239.255.255.250",
],
@@ -674,3 +707,23 @@ def test_classification_lists_do_not_drift_from_the_routes():
actual = _request_body_url_fields()
stale = (GUARDED_BODY_URLS | NOT_A_FETCH_TARGET | KNOWN_UNGUARDED_NEEDS_SCHEME_AWARE_GUARD) - actual
assert not stale, f"Classification entries no longer match any route: {sorted(stale)}"
@pytest.mark.parametrize(
"host",
[
"0xa9.0xfe.0xa9.0xfe",
"169.254.43518",
"0251.0376.0251.0376",
"0169.254.169.254",
"0x7f.1",
"169.254.169.254.",
"metadata.google.internal.",
"127\u30020\u30020\u30021",
"169\uff0e254\uff0e169\uff0e254",
],
)
def test_public_tier_rejects_other_ipv4_notations_over_https(host: str):
"""Over https, so the scheme check cannot be what refuses them."""
with pytest.raises(ValueError):
assert_safe_public_https_url(f"https://{host}/icon.png")
+19 -5
View File
@@ -11,7 +11,7 @@ import pytest
from backend.app.api.routes._url_safety import (
CLOUD_METADATA_IPS,
NUMERIC_IP_RE,
is_numeric_encoded_ip,
unwrap_ipv4_mapped,
)
@@ -37,10 +37,14 @@ def test_cloud_metadata_set_is_frozen():
"0xFFFFFFFF", # uppercase hex
"0",
"4294967295", # max uint32
"0xa9.0xfe.0xa9.0xfe", # dotted hex
"169.254.43518", # short form
"0251.0376.0251.0376", # dotted octal
"0169.254.169.254", # a leading zero
],
)
def test_numeric_ip_re_matches_encoded_forms(candidate):
assert NUMERIC_IP_RE.match(candidate) is not None
def test_numeric_encoded_forms_are_recognised(candidate):
assert is_numeric_encoded_ip(candidate) is True
@pytest.mark.parametrize(
@@ -51,10 +55,12 @@ def test_numeric_ip_re_matches_encoded_forms(candidate):
"spoolman.lan",
"::1",
"localhost",
"3dprinter.lan",
"node-01",
],
)
def test_numeric_ip_re_rejects_normal_forms(candidate):
assert NUMERIC_IP_RE.match(candidate) is None
def test_normal_forms_are_not_numeric_encoded(candidate):
assert is_numeric_encoded_ip(candidate) is False
def test_unwrap_ipv4_mapped_unwraps_mapped_address():
@@ -72,3 +78,11 @@ def test_unwrap_ipv4_mapped_passes_through_pure_ipv4():
def test_unwrap_ipv4_mapped_passes_through_pure_ipv6():
addr = ipaddress.ip_address("2001:db8::1")
assert unwrap_ipv4_mapped(addr) is addr
def test_unwrap_drops_a_zone_id():
"""Ignored on connect to an address that is not link-local: the address
reached is the one without it."""
result = unwrap_ipv4_mapped(ipaddress.ip_address("fd00:ec2::254%251"))
assert result == ipaddress.ip_address("fd00:ec2::254")
assert result in CLOUD_METADATA_IPS