Przeglądaj źródła

Security hardening (maziggy/bambuddy-security #21)

maziggy 1 tydzień temu
rodzic
commit
930a6661d7

+ 42 - 6
backend/app/api/routes/_url_safety.py

@@ -28,7 +28,10 @@ import re
 from urllib.parse import ParseResult, urlparse
 
 # Cloud-provider metadata endpoints — the classic SSRF credential-exfil
-# targets. Both guards reject these unconditionally.
+# targets. Both guards reject these unconditionally. The documented endpoints
+# of the major providers, so best effort by nature. Those inside
+# 169.254.0.0/16 are also covered by the LAN guard's link-local refusal;
+# 100.100.100.200 and the IPv6 ones are refused only through this list.
 CLOUD_METADATA_IPS = frozenset(
     {
         # AWS / GCP / Azure / Oracle / DigitalOcean IMDS
@@ -37,6 +40,21 @@ CLOUD_METADATA_IPS = frozenset(
         ipaddress.ip_address("100.100.100.200"),
         # AWS IMDS IPv6
         ipaddress.ip_address("fd00:ec2::254"),
+        # Tencent Cloud
+        ipaddress.ip_address("169.254.0.23"),
+        # Scaleway
+        ipaddress.ip_address("169.254.42.42"),
+        ipaddress.ip_address("fd00:42::42"),
+        # Oracle Cloud IMDS IPv6
+        ipaddress.ip_address("fd00:c1::a9fe:a9fe"),
+        # GCP metadata server on IPv6 VMs
+        ipaddress.ip_address("fd20:ce::254"),
+        # Akamai / Linode metadata service IPv6
+        ipaddress.ip_address("fd00:a9fe:a9fe::1"),
+        # AWS EKS Pod Identity Agent IPv6 (its IPv4 address is link-local)
+        ipaddress.ip_address("fd00:ec2::23"),
+        # OpenStack metadata service on IPv6 networks
+        ipaddress.ip_address("fe80::a9fe:a9fe"),
     }
 )
 
@@ -49,6 +67,14 @@ CLOUD_METADATA_HOSTNAMES = frozenset(
     {
         "metadata.google.internal",  # GCP
         "metadata.goog",  # GCP short form
+        "metadata.google",  # GCP
+        # A bare "metadata" is completed to metadata.google.internal by the
+        # resolver's search domain on a GCP VM.
+        "metadata",
+        "instance-data",  # AWS, through the search domain
+        "instance-data.ec2.internal",  # AWS
+        "metadata.tencentyun.com",  # Tencent Cloud
+        "api.metadata.cloud.ibm.com",  # IBM Cloud VPC
     }
 )
 
@@ -121,14 +147,17 @@ def assert_safe_lan_service_url(url: str, *, label: str) -> None:
       ``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.
+    - Numeric-encoded IPv4 (decimal ``2130706433``, hex ``0x7f000001``,
+      dotted hex or octal, short forms) — libc and browsers resolve these,
+      but Python's ``ipaddress`` raises ValueError on them, so they would
+      slip past the checks below. Host names not written in ASCII, which
+      HTTP clients map to plain ones before connecting, likewise.
     - Cloud-provider metadata endpoints — the high-value SSRF target, and
-      never a legitimate destination for any of these services.
+      never a legitimate destination for any of these services — and the
+      whole IPv4 link-local range they live in.
     - Multicast and unspecified addresses — pointless as a destination and
       indicative of misuse.
-    - IPv4-mapped IPv6 encodings of any of the above.
+    - IPv4-mapped IPv6 encodings and zone ids 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
@@ -172,3 +201,10 @@ def assert_safe_lan_service_url(url: str, *, label: str) -> None:
 
     if effective.is_multicast or effective.is_unspecified:
         raise ValueError(f"{label} must not point to a multicast or unspecified address")
+
+    # IPv4 link-local is where cloud providers put their metadata endpoints,
+    # listed above or not; a LAN service has no reason to live there. IPv6
+    # link-local and ULA stay reachable, as real LAN devices use them: the
+    # metadata addresses there are refused one by one through the list.
+    if isinstance(effective, ipaddress.IPv4Address) and effective.is_link_local:
+        raise ValueError(f"{label} must not point to a link-local address (169.254.x.x)")

+ 1 - 1
backend/app/services/external_camera.py

@@ -82,7 +82,7 @@ def _blocked_host_reason(hostname: str) -> str | None:
         # intermittent local DNS.
         if host == "localhost" or host.endswith(".localhost"):
             return "localhost"
-        if host in CLOUD_METADATA_HOSTNAMES or host == "metadata.google":
+        if host in CLOUD_METADATA_HOSTNAMES:
             return "a cloud metadata service"
         return None
 

+ 4 - 5
backend/tests/unit/services/test_rest_smart_plug.py

@@ -59,11 +59,10 @@ class TestURLValidation:
         """
         assert service._validate_url("http://127.0.0.1/api") is True
 
-    def test_link_local_allowed(self, service):
-        """Also deliberate: a generic APIPA address is a LAN host like any
-        other. The cloud-metadata address inside that range is blocked by
-        name, not by rejecting the whole /16 — see test_metadata_blocked."""
-        assert service._validate_url("http://169.254.1.1/api") is True
+    def test_link_local_refused(self, service):
+        """The IPv4 link-local range is where cloud providers put their
+        metadata endpoints, listed or not, so the whole /16 is refused."""
+        assert service._validate_url("http://169.254.1.1/api") is False
 
     @pytest.mark.parametrize(
         "url",

+ 10 - 0
backend/tests/unit/test_external_camera_ssrf.py

@@ -285,6 +285,16 @@ class TestTheSameHostsSpelledOtherwise:
             "\u24dcetadata.google.internal",
             "[fd00:ec2::254%251]",
             "[fd00:ec2::254%1]",
+            "metadata",
+            "instance-data",
+            "instance-data.ec2.internal",
+            "metadata.tencentyun.com",
+            "[fd00:42::42]",
+            "[fd00:c1::a9fe:a9fe]",
+            "[fd20:ce::254]",
+            "[fd00:a9fe:a9fe::1]",
+            "[fd00:ec2::23]",
+            "api.metadata.cloud.ibm.com",
         ],
     )
     def test_is_refused(self, host):

+ 33 - 0
backend/tests/unit/test_outbound_url_ssrf_guards.py

@@ -84,6 +84,25 @@ UNIVERSALLY_BLOCKED = [
     "http://\u24dcetadata.google.internal/",
     "http://metadata\u3002google\u3002internal/",
     "http://0xa9\u30020xfe\u30020xa9\u30020xfe/",
+    # Names that reach the metadata endpoint without a lookup of ours: a short
+    # name the cloud's resolver completes, and the providers' own aliases.
+    "http://metadata/computeMetadata/v1/",
+    "http://metadata.google/",
+    "http://instance-data/latest/meta-data/",
+    "http://instance-data.ec2.internal/latest/meta-data/",
+    "http://INSTANCE-DATA./",
+    "http://metadata.tencentyun.com/latest/meta-data/",
+    # Other providers' metadata addresses.
+    "http://169.254.0.23/",
+    "http://169.254.42.42/conf",
+    "http://[fd00:42::42]/",
+    "http://[fd00:c1::a9fe:a9fe]/",
+    "http://[fd20:ce::254]/computeMetadata/v1/",
+    "http://[fd00:a9fe:a9fe::1]/",
+    "http://[fd00:ec2::23]/v1/credentials",
+    "http://[fe80::a9fe:a9fe]/openstack/",
+    "http://[fe80::a9fe:a9fe%25eth0]/",
+    "http://api.metadata.cloud.ibm.com/metadata/v1/instance",
     # 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]/",
@@ -720,10 +739,24 @@ def test_classification_lists_do_not_drift_from_the_routes():
         "169.254.169.254.",
         "metadata.google.internal.",
         "127\u30020\u30020\u30021",
+        "metadata",
+        "instance-data.ec2.internal",
         "169\uff0e254\uff0e169\uff0e254",
+        "api.metadata.cloud.ibm.com",
     ],
 )
 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")
+
+
+@pytest.mark.parametrize("host", ["169.254.1.1", "169.254.99.7", "169.254.255.254", "[::ffff:169.254.10.10]"])
+def test_lan_tier_refuses_the_whole_ipv4_link_local_range(host: str):
+    """Where cloud providers put their metadata endpoints, listed or not."""
+    with pytest.raises(ValueError):
+        assert_safe_lan_service_url(f"http://{host}/", label="URL")
+
+
+def test_lan_tier_still_permits_ipv6_link_local():
+    assert_safe_lan_service_url("http://[fe80::1]:8123/", label="URL")

+ 5 - 0
backend/tests/unit/test_url_safety.py

@@ -10,6 +10,7 @@ import ipaddress
 import pytest
 
 from backend.app.api.routes._url_safety import (
+    CLOUD_METADATA_HOSTNAMES,
     CLOUD_METADATA_IPS,
     is_numeric_encoded_ip,
     unwrap_ipv4_mapped,
@@ -86,3 +87,7 @@ def test_unwrap_drops_a_zone_id():
     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
+
+
+def test_cloud_metadata_names_include_the_short_and_alias_forms():
+    assert {"metadata", "metadata.google", "instance-data", "instance-data.ec2.internal"} <= CLOUD_METADATA_HOSTNAMES