Просмотр исходного кода

Security hardening (maziggy/bambuddy-security #21)

maziggy 1 неделя назад
Родитель
Сommit
930a6661d7

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

@@ -28,7 +28,10 @@ import re
 from urllib.parse import ParseResult, urlparse
 from urllib.parse import ParseResult, urlparse
 
 
 # Cloud-provider metadata endpoints — the classic SSRF credential-exfil
 # 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(
 CLOUD_METADATA_IPS = frozenset(
     {
     {
         # AWS / GCP / Azure / Oracle / DigitalOcean IMDS
         # AWS / GCP / Azure / Oracle / DigitalOcean IMDS
@@ -37,6 +40,21 @@ CLOUD_METADATA_IPS = frozenset(
         ipaddress.ip_address("100.100.100.200"),
         ipaddress.ip_address("100.100.100.200"),
         # AWS IMDS IPv6
         # AWS IMDS IPv6
         ipaddress.ip_address("fd00:ec2::254"),
         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.google.internal",  # GCP
         "metadata.goog",  # GCP short form
         "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
       ``UnsupportedProtocol`` for ``file://``/``gopher://`` etc., so this is
       about returning a clear validation error at configuration time rather
       about returning a clear validation error at configuration time rather
       than an opaque failure at delivery time.
       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
     - 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
     - Multicast and unspecified addresses — pointless as a destination and
       indicative of misuse.
       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
     Symbolic hostnames are otherwise accepted without DNS resolution, matching
     the public-internet guard: resolution here would be both a TOCTOU (DNS can
     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:
     if effective.is_multicast or effective.is_unspecified:
         raise ValueError(f"{label} must not point to a multicast or unspecified address")
         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.
         # intermittent local DNS.
         if host == "localhost" or host.endswith(".localhost"):
         if host == "localhost" or host.endswith(".localhost"):
             return "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 "a cloud metadata service"
         return None
         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
         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(
     @pytest.mark.parametrize(
         "url",
         "url",

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

@@ -285,6 +285,16 @@ class TestTheSameHostsSpelledOtherwise:
             "\u24dcetadata.google.internal",
             "\u24dcetadata.google.internal",
             "[fd00:ec2::254%251]",
             "[fd00:ec2::254%251]",
             "[fd00:ec2::254%1]",
             "[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):
     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://\u24dcetadata.google.internal/",
     "http://metadata\u3002google\u3002internal/",
     "http://metadata\u3002google\u3002internal/",
     "http://0xa9\u30020xfe\u30020xa9\u30020xfe/",
     "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.
     # A zone id on an address that is not link-local is ignored on connect.
     "http://[fd00:ec2::254%251]/",
     "http://[fd00:ec2::254%251]/",
     "http://[fd00:ec2::254%25eth0]/",
     "http://[fd00:ec2::254%25eth0]/",
@@ -720,10 +739,24 @@ def test_classification_lists_do_not_drift_from_the_routes():
         "169.254.169.254.",
         "169.254.169.254.",
         "metadata.google.internal.",
         "metadata.google.internal.",
         "127\u30020\u30020\u30021",
         "127\u30020\u30020\u30021",
+        "metadata",
+        "instance-data.ec2.internal",
         "169\uff0e254\uff0e169\uff0e254",
         "169\uff0e254\uff0e169\uff0e254",
+        "api.metadata.cloud.ibm.com",
     ],
     ],
 )
 )
 def test_public_tier_rejects_other_ipv4_notations_over_https(host: str):
 def test_public_tier_rejects_other_ipv4_notations_over_https(host: str):
     """Over https, so the scheme check cannot be what refuses them."""
     """Over https, so the scheme check cannot be what refuses them."""
     with pytest.raises(ValueError):
     with pytest.raises(ValueError):
         assert_safe_public_https_url(f"https://{host}/icon.png")
         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
 import pytest
 
 
 from backend.app.api.routes._url_safety import (
 from backend.app.api.routes._url_safety import (
+    CLOUD_METADATA_HOSTNAMES,
     CLOUD_METADATA_IPS,
     CLOUD_METADATA_IPS,
     is_numeric_encoded_ip,
     is_numeric_encoded_ip,
     unwrap_ipv4_mapped,
     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"))
     result = unwrap_ipv4_mapped(ipaddress.ip_address("fd00:ec2::254%251"))
     assert result == ipaddress.ip_address("fd00:ec2::254")
     assert result == ipaddress.ip_address("fd00:ec2::254")
     assert result in CLOUD_METADATA_IPS
     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