Skip to content

Commit 61e6d92

Browse files
fix(signing): block AS112, AMT and ORCHIDv2 special-use ranges
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.
1 parent b6b3f47 commit 61e6d92

2 files changed

Lines changed: 88 additions & 6 deletions

File tree

src/adcp/signing/jwks.py

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,9 +68,35 @@
6868
# * ``192.88.99.0/24`` — RFC 7526 deprecated 6to4 relay anycast. Traffic
6969
# sent here is tunnelled by whatever relay answers, which makes the
7070
# destination unattributable; ``is_reserved`` does not cover it.
71+
# * ``192.31.196.0/24`` (RFC 7535 AS112-v4), ``192.52.193.0/24`` (RFC 7450
72+
# AMT), ``192.175.48.0/24`` (RFC 7534 AS112 direct delegation) and
73+
# ``2001:20::/28`` (RFC 7343 ORCHIDv2) — IANA special-use anycast and
74+
# non-routable identifier space. None is ever a legitimate JWKS or webhook
75+
# destination, and none is caught by any flag.
76+
#
77+
# Verified empirically across the supported interpreter matrix (3.10, 3.11,
78+
# 3.12, 3.13): every range above is classified non-reserved by all six flags on
79+
# ALL of them, so each entry is load-bearing on every supported version — none
80+
# is redundant anywhere. In particular ``100.64.0.0/10`` is still
81+
# ``is_private == False`` on 3.12.9 and 3.13.11.
82+
#
83+
# ``not ip.is_global`` is NOT a usable substitute: it reports True (i.e.
84+
# globally reachable) for the 6to4-relay, AS112, AMT and ORCHIDv2 ranges on
85+
# every supported version, so it would close none of these holes.
86+
#
87+
# Ranges deliberately absent because the flags already cover them on every
88+
# supported version — 6to4 ``2002::/16``, Teredo ``2001::/32``, NAT64
89+
# ``64:ff9b::/96``, and IPv4 NAT64 ``192.0.0.8``. Their embedded IPv4 needs no
90+
# decoding: the whole prefix is rejected before any address is unwrapped.
91+
# ``test_ssrf_flag_covered_special_ranges_stay_blocked`` pins that so a future
92+
# CPython reclassification cannot silently open them.
7193
_EXTRA_BLOCKED_NETWORKS: tuple[ipaddress.IPv4Network | ipaddress.IPv6Network, ...] = (
7294
ipaddress.ip_network("100.64.0.0/10"),
7395
ipaddress.ip_network("192.88.99.0/24"),
96+
ipaddress.ip_network("192.31.196.0/24"),
97+
ipaddress.ip_network("192.52.193.0/24"),
98+
ipaddress.ip_network("192.175.48.0/24"),
99+
ipaddress.ip_network("2001:20::/28"),
74100
)
75101

76102
# Recommended destination ports for hardened SSRF-validated outbound HTTP

tests/conformance/signing/test_jwks.py

Lines changed: 62 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
from __future__ import annotations
44

5+
import socket
56
from typing import Any
67
from unittest.mock import patch
78

@@ -19,6 +20,19 @@
1920
# ---- SSRF validation ----
2021

2122

23+
def _addrinfo(ip: str) -> tuple[int, int, int, str, tuple]:
24+
"""Build a `getaddrinfo` record with the sockaddr shape matching the family.
25+
26+
AF_INET carries a 2-tuple `(addr, port)`; AF_INET6 a 4-tuple
27+
`(addr, port, flowinfo, scope_id)`. Parametrised tests mix both families,
28+
and a v6 address in a v4-shaped record would exercise a resolution the
29+
stdlib never actually produces.
30+
"""
31+
if ":" in ip:
32+
return (socket.AF_INET6, socket.SOCK_STREAM, 6, "", (ip, 0, 0, 0))
33+
return (socket.AF_INET, socket.SOCK_STREAM, 6, "", (ip, 0))
34+
35+
2236
@pytest.mark.parametrize(
2337
"host_or_url",
2438
[
@@ -97,25 +111,67 @@ def test_ssrf_blocks_oracle_metadata() -> None:
97111
("192.88.99.0", "RFC 7526 6to4 relay anycast lower bound"),
98112
("192.88.99.1", "RFC 7526 deprecated 6to4 relay anycast"),
99113
("192.88.99.255", "RFC 7526 6to4 relay anycast upper bound"),
114+
("192.31.196.1", "RFC 7535 AS112-v4 anycast"),
115+
("192.52.193.1", "RFC 7450 AMT anycast"),
116+
("192.175.48.1", "RFC 7534 AS112 direct delegation"),
117+
("2001:20::1", "RFC 7343 ORCHIDv2 (IPv6)"),
100118
],
101119
)
102120
def test_ssrf_blocks_ranges_python_flags_miss(resolved_ip: str, why: str) -> None:
103121
"""Reserved ranges that `ipaddress`'s own flags do not classify.
104122
105-
`is_private` is False across 100.64.0.0/10 — RFC 6598 designates shared
106-
address space, not private space — so the flag check alone lets carrier
107-
and container-network addresses through. AdCP 3.1.1 names the range in
108-
the deny list a fetcher MUST apply ("Webhook URL validation (SSRF)",
109-
step 2).
123+
Every range here is non-reserved under all six flags on the whole
124+
supported interpreter matrix (3.10-3.13) — verified empirically, not
125+
assumed. `is_private` is False across 100.64.0.0/10 because RFC 6598
126+
designates *shared* address space rather than private space, and it
127+
remains False on 3.12.9 / 3.13.11, so the entry is load-bearing on every
128+
supported version rather than redundant on newer ones.
129+
130+
AdCP 3.1.1 names 100.64.0.0/10 in the deny list a fetcher MUST apply
131+
("Webhook URL validation (SSRF)", step 2). The remainder are IANA
132+
special-use anycast and non-routable identifier space, never a legitimate
133+
JWKS or webhook destination.
110134
"""
111135
with patch(
112136
"adcp.signing.jwks.socket.getaddrinfo",
113-
return_value=[(2, 1, 6, "", (resolved_ip, 0))],
137+
return_value=[_addrinfo(resolved_ip)],
114138
):
115139
with pytest.raises(SSRFValidationError, match="reserved range"):
116140
validate_jwks_uri("https://buyer-supplied.example/jwks.json")
117141

118142

143+
@pytest.mark.parametrize(
144+
("resolved_ip", "why"),
145+
[
146+
("2002:a9fe:a9fe::", "6to4 2002::/16 embedding 169.254.169.254"),
147+
("2002:0a00:0001::", "6to4 2002::/16 embedding 10.0.0.1"),
148+
("2001::1", "Teredo 2001::/32"),
149+
("64:ff9b::a9fe:a9fe", "NAT64 64:ff9b::/96 embedding 169.254.169.254"),
150+
("64:ff9b::a00:1", "NAT64 64:ff9b::/96 embedding 10.0.0.1"),
151+
("192.0.0.8", "IPv4 NAT64 dummy address"),
152+
],
153+
)
154+
def test_ssrf_flag_covered_special_ranges_stay_blocked(resolved_ip: str, why: str) -> None:
155+
"""Special-use ranges the flags already cover, pinned against regression.
156+
157+
These are deliberately NOT in `_EXTRA_BLOCKED_NETWORKS`: `ipaddress`
158+
classifies the whole prefix reserved on every supported version, so the
159+
embedded IPv4 in the tunnel forms needs no decoding — the address is
160+
rejected before anything is unwrapped.
161+
162+
That makes the block a dependency on CPython's classification rather than
163+
on our own list, which is exactly the kind of assumption worth pinning: if
164+
a future release reclassified any of these, the deny list would need an
165+
explicit entry and this test is what would say so.
166+
"""
167+
with patch(
168+
"adcp.signing.jwks.socket.getaddrinfo",
169+
return_value=[_addrinfo(resolved_ip)],
170+
):
171+
with pytest.raises(SSRFValidationError):
172+
validate_jwks_uri("https://buyer-supplied.example/jwks.json")
173+
174+
119175
@pytest.mark.parametrize(
120176
("resolved_ip", "why"),
121177
[

0 commit comments

Comments
 (0)