Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 45 additions & 0 deletions src/adcp/signing/jwks.py
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,50 @@
}
)

# 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.
# * ``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
# deployments. AdCP itself does not constrain ``pushNotificationConfig.url``
# ports (see ``schemas/cache/core/push-notification-config.json``), so the
Expand Down Expand Up @@ -272,6 +316,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
Expand Down
144 changes: 144 additions & 0 deletions tests/conformance/signing/test_jwks.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

from __future__ import annotations

import socket
from typing import Any
from unittest.mock import patch

Expand All @@ -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",
[
Expand Down Expand Up @@ -88,6 +102,136 @@ 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.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.

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=[_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"),
[
("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
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.
Expand Down
Loading