From 1c4de11127f494faf4ce9e6650b0b4d6c6a70f86 Mon Sep 17 00:00:00 2001 From: "Constantine.mirin" Date: Mon, 27 Jul 2026 19:51:32 +0200 Subject: [PATCH 1/3] fix(signing): block CGNAT and 6to4 relay ranges in SSRF validation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `resolve_and_validate_host` classifies reserved addresses with Python's `ipaddress` flags (`is_private`, `is_loopback`, `is_link_local`, `is_multicast`, `is_reserved`, `is_unspecified`) plus an explicit cloud-metadata set. Two ranges fall through every one of those flags: * `100.64.0.0/10` — RFC 6598 carrier-grade NAT. `is_private` is False here because RFC 6598 designates *shared* address space rather than private space. AdCP 3.1.1 names this range explicitly in the deny list a fetcher MUST apply before any outbound fetch to a counterparty URL ("Webhook URL validation (SSRF)", step 2), so the SDK was accepting destinations its own spec requires it to reject. The range is routable inside carrier and container networks, which makes it a live SSRF path into an operator's internal network. * `192.88.99.0/24` — RFC 7526 deprecated 6to4 relay anycast. Traffic sent here is tunnelled by whichever relay answers, so the real destination is unattributable. `is_reserved` does not cover it. Adds `_EXTRA_BLOCKED_NETWORKS` and folds it into the existing check. The new ranges sit behind the same `allow_private` gate as every other reserved range, so on-prem and test deployments keep their escape hatch; the cloud-metadata list stays unconditional. Note the interaction: Alibaba's metadata IP `100.100.100.200` lives inside `100.64.0.0/10`, so it was previously reachable only-by-name through the metadata list. It is now covered twice, and a regression test pins that the metadata check still wins under `allow_private=True`. Behaviour change is purely additive rejection of address space that is never a legitimate webhook or JWKS destination. No public API change. --- src/adcp/signing/jwks.py | 19 ++++++++++ tests/conformance/signing/test_jwks.py | 49 ++++++++++++++++++++++++++ 2 files changed, 68 insertions(+) diff --git a/src/adcp/signing/jwks.py b/src/adcp/signing/jwks.py index 92399807..91a9a239 100644 --- a/src/adcp/signing/jwks.py +++ b/src/adcp/signing/jwks.py @@ -55,6 +55,24 @@ } ) +# Reserved ranges that Python's ``ipaddress`` flags do NOT classify, so the +# ``is_private``/``is_reserved``/... check below misses them on its own. +# +# * ``100.64.0.0/10`` — RFC 6598 carrier-grade NAT. AdCP names this range +# explicitly in the reserved-range deny list a fetcher MUST apply +# (spec 3.1.1, "Webhook URL validation (SSRF)", step 2), but +# ``is_private`` is False for it: RFC 6598 designates shared address +# space, not private space. It is routable inside carrier and container +# networks (and is where Alibaba's metadata endpoint lives), so leaving +# it open is a real SSRF path into an operator's internal network. +# * ``192.88.99.0/24`` — RFC 7526 deprecated 6to4 relay anycast. Traffic +# sent here is tunnelled by whatever relay answers, which makes the +# destination unattributable; ``is_reserved`` does not cover it. +_EXTRA_BLOCKED_NETWORKS: tuple[ipaddress.IPv4Network | ipaddress.IPv6Network, ...] = ( + ipaddress.ip_network("100.64.0.0/10"), + ipaddress.ip_network("192.88.99.0/24"), +) + # Recommended destination ports for hardened SSRF-validated outbound HTTP # deployments. AdCP itself does not constrain ``pushNotificationConfig.url`` # ports (see ``schemas/cache/core/push-notification-config.json``), so the @@ -272,6 +290,7 @@ def resolve_and_validate_host( or ip.is_multicast or ip.is_reserved or ip.is_unspecified + or any(ip in network for network in _EXTRA_BLOCKED_NETWORKS) ): last_rejection = f"resolved IP {ip} is in a reserved range" # Historical behavior of validate_jwks_uri was to raise on diff --git a/tests/conformance/signing/test_jwks.py b/tests/conformance/signing/test_jwks.py index a893a490..bbb20901 100644 --- a/tests/conformance/signing/test_jwks.py +++ b/tests/conformance/signing/test_jwks.py @@ -88,6 +88,55 @@ def test_ssrf_blocks_oracle_metadata() -> None: validate_jwks_uri("http://oracle-metadata.example/jwks.json") +@pytest.mark.parametrize( + ("resolved_ip", "why"), + [ + ("100.64.0.1", "RFC 6598 CGNAT lower bound"), + ("100.100.100.1", "CGNAT mid-range — Alibaba metadata's neighbourhood"), + ("100.127.255.254", "RFC 6598 CGNAT upper bound"), + ("192.88.99.1", "RFC 7526 deprecated 6to4 relay anycast"), + ], +) +def test_ssrf_blocks_ranges_python_flags_miss(resolved_ip: str, why: str) -> None: + """Reserved ranges that `ipaddress`'s own flags do not classify. + + `is_private` is False across 100.64.0.0/10 — RFC 6598 designates shared + address space, not private space — so the flag check alone lets carrier + and container-network addresses through. AdCP 3.1.1 names the range in + the deny list a fetcher MUST apply ("Webhook URL validation (SSRF)", + step 2). + """ + with patch( + "adcp.signing.jwks.socket.getaddrinfo", + return_value=[(2, 1, 6, "", (resolved_ip, 0))], + ): + with pytest.raises(SSRFValidationError, match="reserved range"): + validate_jwks_uri("https://buyer-supplied.example/jwks.json") + + +def test_ssrf_cgnat_honours_allow_private_override() -> None: + """CGNAT follows the same `allow_private` gate as every other reserved + range — it is not unconditional like the cloud-metadata list, so on-prem + and test deployments keep their documented escape hatch.""" + with patch( + "adcp.signing.jwks.socket.getaddrinfo", + return_value=[(2, 1, 6, "", ("100.64.0.1", 0))], + ): + validate_jwks_uri("https://cgnat-host.example/jwks.json", allow_private=True) + + +def test_ssrf_alibaba_metadata_blocked_despite_allow_private() -> None: + """Regression guard for the interaction between the new CGNAT range and + the metadata list: 100.100.100.200 sits inside 100.64.0.0/10, and the + metadata check must keep winning so `allow_private` cannot unblock it.""" + with patch( + "adcp.signing.jwks.socket.getaddrinfo", + return_value=[(2, 1, 6, "", ("100.100.100.200", 0))], + ): + with pytest.raises(SSRFValidationError, match="metadata"): + validate_jwks_uri("http://alibaba-metadata.example/jwks.json", allow_private=True) + + def test_ssrf_caps_resolved_address_scan() -> None: # Build 100 records where the first 32 are public and the 33rd is internal. # With the cap at 32, the scan stops before reaching the loopback address. From ce2f023939e81c969bb6175c271f7ca7e9f1f511 Mon Sep 17 00:00:00 2001 From: "Constantine.mirin" Date: Mon, 27 Jul 2026 20:53:51 +0200 Subject: [PATCH 2/3] test(signing): cover IPv6 fall-through and 6to4 relay bounds MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review nits on the CGNAT/6to4 deny-list change. `_EXTRA_BLOCKED_NETWORKS` holds only IPv4 networks while the membership test runs against a possibly-IPv6 address. That relies on CPython's `_BaseNetwork.__contains__` returning False across versions rather than raising — load-bearing behaviour that had no test, only an assumption. Adds two public-IPv6 cases that must pass rather than raise. Also fills the 6to4 relay range's coverage to match CGNAT's: both /24 bounds, and an `allow_private` override case, so the second range is pinned directly instead of inferred from the first. Tests only; no behaviour change. --- tests/conformance/signing/test_jwks.py | 39 ++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/tests/conformance/signing/test_jwks.py b/tests/conformance/signing/test_jwks.py index bbb20901..e16ad1a1 100644 --- a/tests/conformance/signing/test_jwks.py +++ b/tests/conformance/signing/test_jwks.py @@ -94,7 +94,9 @@ def test_ssrf_blocks_oracle_metadata() -> None: ("100.64.0.1", "RFC 6598 CGNAT lower bound"), ("100.100.100.1", "CGNAT mid-range — Alibaba metadata's neighbourhood"), ("100.127.255.254", "RFC 6598 CGNAT upper bound"), + ("192.88.99.0", "RFC 7526 6to4 relay anycast lower bound"), ("192.88.99.1", "RFC 7526 deprecated 6to4 relay anycast"), + ("192.88.99.255", "RFC 7526 6to4 relay anycast upper bound"), ], ) def test_ssrf_blocks_ranges_python_flags_miss(resolved_ip: str, why: str) -> None: @@ -114,6 +116,43 @@ def test_ssrf_blocks_ranges_python_flags_miss(resolved_ip: str, why: str) -> Non validate_jwks_uri("https://buyer-supplied.example/jwks.json") +@pytest.mark.parametrize( + ("resolved_ip", "why"), + [ + ("2606:4700:4700::1111", "public IPv6"), + ("2001:4860:4860::8888", "public IPv6, different allocation"), + ], +) +def test_ssrf_ipv6_accepted_against_ipv4_only_extra_networks(resolved_ip: str, why: str) -> None: + """A public IPv6 resolution must pass, not raise, not crash. + + `_EXTRA_BLOCKED_NETWORKS` holds only IPv4 networks, and the membership + test runs against a possibly-IPv6 address. CPython's + `_BaseNetwork.__contains__` returns False on a version mismatch rather + than raising (only the ordering operators raise), so a v6 address falls + through to the flag checks. That behaviour is load-bearing here, so pin + it with a test rather than leaving it as an assumption about CPython. + """ + with patch( + "adcp.signing.jwks.socket.getaddrinfo", + return_value=[(10, 1, 6, "", (resolved_ip, 0, 0, 0))], + ): + validate_jwks_uri("https://ipv6-host.example/jwks.json") + + +def test_ssrf_6to4_relay_honours_allow_private_override() -> None: + """The 6to4 relay range follows the same `allow_private` gate as CGNAT. + + Only CGNAT had override coverage; both new ranges sit behind the same + gate, so pin both rather than inferring the second from the first. + """ + with patch( + "adcp.signing.jwks.socket.getaddrinfo", + return_value=[(2, 1, 6, "", ("192.88.99.1", 0))], + ): + validate_jwks_uri("https://relay-host.example/jwks.json", allow_private=True) + + def test_ssrf_cgnat_honours_allow_private_override() -> None: """CGNAT follows the same `allow_private` gate as every other reserved range — it is not unconditional like the cloud-metadata list, so on-prem From 104c642ecae4d3e5818d71733475ba37291fbaa1 Mon Sep 17 00:00:00 2001 From: "Constantine.mirin" Date: Mon, 27 Jul 2026 21:24:30 +0200 Subject: [PATCH 3/3] fix(signing): block AS112, AMT and ORCHIDv2 special-use ranges MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review follow-up: the deny list covered the two highest-value ranges the flag check misses, but not the rest of the flag-missed set. Verified across the whole supported interpreter matrix (3.10, 3.11, 3.12, 3.13) rather than assumed — four more ranges are non-reserved under all six flags on every one of them: 192.31.196.0/24 RFC 7535 AS112-v4 anycast 192.52.193.0/24 RFC 7450 AMT anycast 192.175.48.0/24 RFC 7534 AS112 direct delegation 2001:20::/28 RFC 7343 ORCHIDv2 None is ever a legitimate JWKS or webhook destination. The ORCHIDv2 entry also makes the tuple's IPv4|IPv6 annotation accurate rather than forward-looking. Three ranges named in the follow-up are NOT added, because the flags already reject them on every supported version: 6to4 2002::/16, Teredo 2001::/32, and IPv4 NAT64 192.0.0.8 (plus NAT64 64:ff9b::/96 from an earlier round). Their embedded IPv4 needs no decoding — the whole prefix is rejected before anything is unwrapped. Since that block depends on CPython's classification rather than on our list, test_ssrf_flag_covered_special_ranges_stay_blocked pins it, so a future reclassification surfaces as a failure instead of a silent hole. Two corrections to the follow-up, both measured: - 100.64.0.0/10 is is_private == False on 3.12.9 and 3.13.11, not just on pre-3.12.4 interpreters. The entry is load-bearing on every supported version, so no "redundant on newer Python" comment is warranted. - `not ip.is_global` is not a usable substitute gate: is_global reports True for the 6to4-relay, AS112, AMT and ORCHIDv2 ranges on every supported version, so it would close none of these holes. Also fixes the getaddrinfo mock shape: parametrised cases now build AF_INET 2-tuple vs AF_INET6 4-tuple sockaddrs via a helper, so the IPv6 cases exercise a record the stdlib would actually produce. Purely additive rejection of never-legitimate destinations; no public API change. --- src/adcp/signing/jwks.py | 26 ++++++++++ tests/conformance/signing/test_jwks.py | 68 +++++++++++++++++++++++--- 2 files changed, 88 insertions(+), 6 deletions(-) diff --git a/src/adcp/signing/jwks.py b/src/adcp/signing/jwks.py index 91a9a239..554cfd20 100644 --- a/src/adcp/signing/jwks.py +++ b/src/adcp/signing/jwks.py @@ -68,9 +68,35 @@ # * ``192.88.99.0/24`` — RFC 7526 deprecated 6to4 relay anycast. Traffic # sent here is tunnelled by whatever relay answers, which makes the # destination unattributable; ``is_reserved`` does not cover it. +# * ``192.31.196.0/24`` (RFC 7535 AS112-v4), ``192.52.193.0/24`` (RFC 7450 +# AMT), ``192.175.48.0/24`` (RFC 7534 AS112 direct delegation) and +# ``2001:20::/28`` (RFC 7343 ORCHIDv2) — IANA special-use anycast and +# non-routable identifier space. None is ever a legitimate JWKS or webhook +# destination, and none is caught by any flag. +# +# Verified empirically across the supported interpreter matrix (3.10, 3.11, +# 3.12, 3.13): every range above is classified non-reserved by all six flags on +# ALL of them, so each entry is load-bearing on every supported version — none +# is redundant anywhere. In particular ``100.64.0.0/10`` is still +# ``is_private == False`` on 3.12.9 and 3.13.11. +# +# ``not ip.is_global`` is NOT a usable substitute: it reports True (i.e. +# globally reachable) for the 6to4-relay, AS112, AMT and ORCHIDv2 ranges on +# every supported version, so it would close none of these holes. +# +# Ranges deliberately absent because the flags already cover them on every +# supported version — 6to4 ``2002::/16``, Teredo ``2001::/32``, NAT64 +# ``64:ff9b::/96``, and IPv4 NAT64 ``192.0.0.8``. Their embedded IPv4 needs no +# decoding: the whole prefix is rejected before any address is unwrapped. +# ``test_ssrf_flag_covered_special_ranges_stay_blocked`` pins that so a future +# CPython reclassification cannot silently open them. _EXTRA_BLOCKED_NETWORKS: tuple[ipaddress.IPv4Network | ipaddress.IPv6Network, ...] = ( ipaddress.ip_network("100.64.0.0/10"), ipaddress.ip_network("192.88.99.0/24"), + ipaddress.ip_network("192.31.196.0/24"), + ipaddress.ip_network("192.52.193.0/24"), + ipaddress.ip_network("192.175.48.0/24"), + ipaddress.ip_network("2001:20::/28"), ) # Recommended destination ports for hardened SSRF-validated outbound HTTP diff --git a/tests/conformance/signing/test_jwks.py b/tests/conformance/signing/test_jwks.py index e16ad1a1..9aae2ef7 100644 --- a/tests/conformance/signing/test_jwks.py +++ b/tests/conformance/signing/test_jwks.py @@ -2,6 +2,7 @@ from __future__ import annotations +import socket from typing import Any from unittest.mock import patch @@ -19,6 +20,19 @@ # ---- SSRF validation ---- +def _addrinfo(ip: str) -> tuple[int, int, int, str, tuple]: + """Build a `getaddrinfo` record with the sockaddr shape matching the family. + + AF_INET carries a 2-tuple `(addr, port)`; AF_INET6 a 4-tuple + `(addr, port, flowinfo, scope_id)`. Parametrised tests mix both families, + and a v6 address in a v4-shaped record would exercise a resolution the + stdlib never actually produces. + """ + if ":" in ip: + return (socket.AF_INET6, socket.SOCK_STREAM, 6, "", (ip, 0, 0, 0)) + return (socket.AF_INET, socket.SOCK_STREAM, 6, "", (ip, 0)) + + @pytest.mark.parametrize( "host_or_url", [ @@ -97,25 +111,67 @@ def test_ssrf_blocks_oracle_metadata() -> None: ("192.88.99.0", "RFC 7526 6to4 relay anycast lower bound"), ("192.88.99.1", "RFC 7526 deprecated 6to4 relay anycast"), ("192.88.99.255", "RFC 7526 6to4 relay anycast upper bound"), + ("192.31.196.1", "RFC 7535 AS112-v4 anycast"), + ("192.52.193.1", "RFC 7450 AMT anycast"), + ("192.175.48.1", "RFC 7534 AS112 direct delegation"), + ("2001:20::1", "RFC 7343 ORCHIDv2 (IPv6)"), ], ) def test_ssrf_blocks_ranges_python_flags_miss(resolved_ip: str, why: str) -> None: """Reserved ranges that `ipaddress`'s own flags do not classify. - `is_private` is False across 100.64.0.0/10 — RFC 6598 designates shared - address space, not private space — so the flag check alone lets carrier - and container-network addresses through. AdCP 3.1.1 names the range in - the deny list a fetcher MUST apply ("Webhook URL validation (SSRF)", - step 2). + Every range here is non-reserved under all six flags on the whole + supported interpreter matrix (3.10-3.13) — verified empirically, not + assumed. `is_private` is False across 100.64.0.0/10 because RFC 6598 + designates *shared* address space rather than private space, and it + remains False on 3.12.9 / 3.13.11, so the entry is load-bearing on every + supported version rather than redundant on newer ones. + + AdCP 3.1.1 names 100.64.0.0/10 in the deny list a fetcher MUST apply + ("Webhook URL validation (SSRF)", step 2). The remainder are IANA + special-use anycast and non-routable identifier space, never a legitimate + JWKS or webhook destination. """ with patch( "adcp.signing.jwks.socket.getaddrinfo", - return_value=[(2, 1, 6, "", (resolved_ip, 0))], + return_value=[_addrinfo(resolved_ip)], ): with pytest.raises(SSRFValidationError, match="reserved range"): validate_jwks_uri("https://buyer-supplied.example/jwks.json") +@pytest.mark.parametrize( + ("resolved_ip", "why"), + [ + ("2002:a9fe:a9fe::", "6to4 2002::/16 embedding 169.254.169.254"), + ("2002:0a00:0001::", "6to4 2002::/16 embedding 10.0.0.1"), + ("2001::1", "Teredo 2001::/32"), + ("64:ff9b::a9fe:a9fe", "NAT64 64:ff9b::/96 embedding 169.254.169.254"), + ("64:ff9b::a00:1", "NAT64 64:ff9b::/96 embedding 10.0.0.1"), + ("192.0.0.8", "IPv4 NAT64 dummy address"), + ], +) +def test_ssrf_flag_covered_special_ranges_stay_blocked(resolved_ip: str, why: str) -> None: + """Special-use ranges the flags already cover, pinned against regression. + + These are deliberately NOT in `_EXTRA_BLOCKED_NETWORKS`: `ipaddress` + classifies the whole prefix reserved on every supported version, so the + embedded IPv4 in the tunnel forms needs no decoding — the address is + rejected before anything is unwrapped. + + That makes the block a dependency on CPython's classification rather than + on our own list, which is exactly the kind of assumption worth pinning: if + a future release reclassified any of these, the deny list would need an + explicit entry and this test is what would say so. + """ + with patch( + "adcp.signing.jwks.socket.getaddrinfo", + return_value=[_addrinfo(resolved_ip)], + ): + with pytest.raises(SSRFValidationError): + validate_jwks_uri("https://buyer-supplied.example/jwks.json") + + @pytest.mark.parametrize( ("resolved_ip", "why"), [