From 9c88e497158873109c87c481b68bcc3f138ceae5 Mon Sep 17 00:00:00 2001 From: Yujong Lee Date: Sat, 10 Oct 2026 09:54:58 -0700 Subject: [PATCH 1/3] fix(rust): widen the media SSRF blocklist to the IANA special-purpose ranges The Rust blocklist missed the Azure Wire Server, 6to4 and several IPv6 ranges that Python rejects through ipaddress.is_global, such as Teredo and the rest of 2001::/23, 100::/64 and 64:ff9b:1::/48. Express it as a network table following the IANA special-purpose registries. It is a superset of the Python _is_blocked_ip policy, pinned by a CPython-generated fixture (generated/blocked_ips.json) with Rust-only blocks listed in KNOWN_OVER_BLOCKED with their reason. Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- litellm-rust/crates/http/AGENTS.md | 1 + .../crates/http/generated/blocked_ips.json | 697 ++++++++++++++++++ .../http/scripts/generate_blocked_ips.py | 121 +++ litellm-rust/crates/http/src/media.rs | 222 +++++- 4 files changed, 1010 insertions(+), 31 deletions(-) create mode 100644 litellm-rust/crates/http/generated/blocked_ips.json create mode 100644 litellm-rust/crates/http/scripts/generate_blocked_ips.py diff --git a/litellm-rust/crates/http/AGENTS.md b/litellm-rust/crates/http/AGENTS.md index db29b59f80e..d573f04396a 100644 --- a/litellm-rust/crates/http/AGENTS.md +++ b/litellm-rust/crates/http/AGENTS.md @@ -5,3 +5,4 @@ - Apply provider-supplied header decisions without importing provider implementations - Preserve existing header precedence, duplicate handling, and unrelated forwarded headers - Verify observable request behavior rather than the structure of helper functions +- The media SSRF blocklist in `media.rs` follows the IANA special-purpose registries and is a superset of the Python `_is_blocked_ip` policy. `generated/blocked_ips.json` is CPython output from `scripts/generate_blocked_ips.py`, never hand-edited. Regenerate it when the Python policy or probe list changes, and record a Rust-only block in `KNOWN_OVER_BLOCKED` with its reason diff --git a/litellm-rust/crates/http/generated/blocked_ips.json b/litellm-rust/crates/http/generated/blocked_ips.json new file mode 100644 index 00000000000..0302e3e692d --- /dev/null +++ b/litellm-rust/crates/http/generated/blocked_ips.json @@ -0,0 +1,697 @@ +{ + "python": "3.12.13", + "rows": [ + { + "address": "0.0.0.0", + "blocked": true + }, + { + "address": "0.255.255.255", + "blocked": true + }, + { + "address": "1.0.0.0", + "blocked": false + }, + { + "address": "9.255.255.255", + "blocked": false + }, + { + "address": "10.0.0.0", + "blocked": true + }, + { + "address": "10.255.255.255", + "blocked": true + }, + { + "address": "11.0.0.0", + "blocked": false + }, + { + "address": "100.63.255.255", + "blocked": false + }, + { + "address": "100.64.0.0", + "blocked": true + }, + { + "address": "100.127.255.255", + "blocked": true + }, + { + "address": "100.128.0.0", + "blocked": false + }, + { + "address": "126.255.255.255", + "blocked": false + }, + { + "address": "127.0.0.0", + "blocked": true + }, + { + "address": "127.255.255.255", + "blocked": true + }, + { + "address": "128.0.0.0", + "blocked": false + }, + { + "address": "168.63.129.15", + "blocked": false + }, + { + "address": "168.63.129.16", + "blocked": true + }, + { + "address": "168.63.129.17", + "blocked": false + }, + { + "address": "169.253.255.255", + "blocked": false + }, + { + "address": "169.254.0.0", + "blocked": true + }, + { + "address": "169.254.255.255", + "blocked": true + }, + { + "address": "169.255.0.0", + "blocked": false + }, + { + "address": "172.15.255.255", + "blocked": false + }, + { + "address": "172.16.0.0", + "blocked": true + }, + { + "address": "172.31.255.255", + "blocked": true + }, + { + "address": "172.32.0.0", + "blocked": false + }, + { + "address": "191.255.255.255", + "blocked": false + }, + { + "address": "192.0.0.0", + "blocked": true + }, + { + "address": "192.0.0.255", + "blocked": true + }, + { + "address": "192.0.1.0", + "blocked": false + }, + { + "address": "192.0.0.8", + "blocked": true + }, + { + "address": "192.0.0.9", + "blocked": true + }, + { + "address": "192.0.0.10", + "blocked": true + }, + { + "address": "192.0.0.11", + "blocked": true + }, + { + "address": "192.0.0.169", + "blocked": true + }, + { + "address": "192.0.0.170", + "blocked": true + }, + { + "address": "192.0.0.171", + "blocked": true + }, + { + "address": "192.0.0.172", + "blocked": true + }, + { + "address": "192.0.1.255", + "blocked": false + }, + { + "address": "192.0.2.0", + "blocked": true + }, + { + "address": "192.0.2.255", + "blocked": true + }, + { + "address": "192.0.3.0", + "blocked": false + }, + { + "address": "192.31.195.255", + "blocked": false + }, + { + "address": "192.31.196.0", + "blocked": false + }, + { + "address": "192.31.196.255", + "blocked": false + }, + { + "address": "192.31.197.0", + "blocked": false + }, + { + "address": "192.52.192.255", + "blocked": false + }, + { + "address": "192.52.193.0", + "blocked": false + }, + { + "address": "192.52.193.255", + "blocked": false + }, + { + "address": "192.52.194.0", + "blocked": false + }, + { + "address": "192.88.98.255", + "blocked": false + }, + { + "address": "192.88.99.0", + "blocked": false + }, + { + "address": "192.88.99.255", + "blocked": false + }, + { + "address": "192.88.100.0", + "blocked": false + }, + { + "address": "192.167.255.255", + "blocked": false + }, + { + "address": "192.168.0.0", + "blocked": true + }, + { + "address": "192.168.255.255", + "blocked": true + }, + { + "address": "192.169.0.0", + "blocked": false + }, + { + "address": "192.175.47.255", + "blocked": false + }, + { + "address": "192.175.48.0", + "blocked": false + }, + { + "address": "192.175.48.255", + "blocked": false + }, + { + "address": "192.175.49.0", + "blocked": false + }, + { + "address": "198.17.255.255", + "blocked": false + }, + { + "address": "198.18.0.0", + "blocked": true + }, + { + "address": "198.19.255.255", + "blocked": true + }, + { + "address": "198.20.0.0", + "blocked": false + }, + { + "address": "198.51.99.255", + "blocked": false + }, + { + "address": "198.51.100.0", + "blocked": true + }, + { + "address": "198.51.100.255", + "blocked": true + }, + { + "address": "198.51.101.0", + "blocked": false + }, + { + "address": "203.0.112.255", + "blocked": false + }, + { + "address": "203.0.113.0", + "blocked": true + }, + { + "address": "203.0.113.255", + "blocked": true + }, + { + "address": "203.0.114.0", + "blocked": false + }, + { + "address": "223.255.255.255", + "blocked": false + }, + { + "address": "224.0.0.0", + "blocked": true + }, + { + "address": "239.255.255.255", + "blocked": true + }, + { + "address": "240.0.0.0", + "blocked": true + }, + { + "address": "255.255.255.255", + "blocked": true + }, + { + "address": "255.255.255.254", + "blocked": true + }, + { + "address": "::", + "blocked": true + }, + { + "address": "::1", + "blocked": true + }, + { + "address": "::2", + "blocked": false + }, + { + "address": "::fffe:ffff:ffff", + "blocked": false + }, + { + "address": "::ffff:0.0.0.0", + "blocked": true + }, + { + "address": "::ffff:255.255.255.255", + "blocked": true + }, + { + "address": "::1:0:0:0", + "blocked": false + }, + { + "address": "64:ff9a:ffff:ffff:ffff:ffff:ffff:ffff", + "blocked": false + }, + { + "address": "64:ff9b::", + "blocked": false + }, + { + "address": "64:ff9b::ffff:ffff", + "blocked": false + }, + { + "address": "64:ff9b::1:0:0", + "blocked": false + }, + { + "address": "64:ff9b:0:ffff:ffff:ffff:ffff:ffff", + "blocked": false + }, + { + "address": "64:ff9b:1::", + "blocked": true + }, + { + "address": "64:ff9b:1:ffff:ffff:ffff:ffff:ffff", + "blocked": true + }, + { + "address": "64:ff9b:2::", + "blocked": false + }, + { + "address": "ff:ffff:ffff:ffff:ffff:ffff:ffff:ffff", + "blocked": false + }, + { + "address": "100::", + "blocked": true + }, + { + "address": "100::ffff:ffff:ffff:ffff", + "blocked": true + }, + { + "address": "100:0:0:1::", + "blocked": false + }, + { + "address": "2000:ffff:ffff:ffff:ffff:ffff:ffff:ffff", + "blocked": false + }, + { + "address": "2001::", + "blocked": true + }, + { + "address": "2001:1ff:ffff:ffff:ffff:ffff:ffff:ffff", + "blocked": true + }, + { + "address": "2001:200::", + "blocked": false + }, + { + "address": "2001:0:ffff:ffff:ffff:ffff:ffff:ffff", + "blocked": true + }, + { + "address": "2001:1::", + "blocked": true + }, + { + "address": "2001:1::1", + "blocked": false + }, + { + "address": "2001:1::2", + "blocked": false + }, + { + "address": "2001:1::3", + "blocked": true + }, + { + "address": "2001:1:ffff:ffff:ffff:ffff:ffff:ffff", + "blocked": true + }, + { + "address": "2001:2::", + "blocked": true + }, + { + "address": "2001:2:0:ffff:ffff:ffff:ffff:ffff", + "blocked": true + }, + { + "address": "2001:2:1::", + "blocked": true + }, + { + "address": "2001:2:ffff:ffff:ffff:ffff:ffff:ffff", + "blocked": true + }, + { + "address": "2001:3::", + "blocked": false + }, + { + "address": "2001:3:ffff:ffff:ffff:ffff:ffff:ffff", + "blocked": false + }, + { + "address": "2001:4::", + "blocked": true + }, + { + "address": "2001:4:111:ffff:ffff:ffff:ffff:ffff", + "blocked": true + }, + { + "address": "2001:4:112::", + "blocked": false + }, + { + "address": "2001:4:112:ffff:ffff:ffff:ffff:ffff", + "blocked": false + }, + { + "address": "2001:4:113::", + "blocked": true + }, + { + "address": "2001:f:ffff:ffff:ffff:ffff:ffff:ffff", + "blocked": true + }, + { + "address": "2001:10::", + "blocked": true + }, + { + "address": "2001:1f:ffff:ffff:ffff:ffff:ffff:ffff", + "blocked": true + }, + { + "address": "2001:20::", + "blocked": false + }, + { + "address": "2001:2f:ffff:ffff:ffff:ffff:ffff:ffff", + "blocked": false + }, + { + "address": "2001:30::", + "blocked": false + }, + { + "address": "2001:3f:ffff:ffff:ffff:ffff:ffff:ffff", + "blocked": false + }, + { + "address": "2001:40::", + "blocked": true + }, + { + "address": "2001:db7:ffff:ffff:ffff:ffff:ffff:ffff", + "blocked": false + }, + { + "address": "2001:db8::", + "blocked": true + }, + { + "address": "2001:db8:ffff:ffff:ffff:ffff:ffff:ffff", + "blocked": true + }, + { + "address": "2001:db9::", + "blocked": false + }, + { + "address": "2001:ffff:ffff:ffff:ffff:ffff:ffff:ffff", + "blocked": false + }, + { + "address": "2002::", + "blocked": true + }, + { + "address": "2002:ffff:ffff:ffff:ffff:ffff:ffff:ffff", + "blocked": true + }, + { + "address": "2003::", + "blocked": false + }, + { + "address": "3ffe:ffff:ffff:ffff:ffff:ffff:ffff:ffff", + "blocked": false + }, + { + "address": "3fff::", + "blocked": true + }, + { + "address": "3fff:fff:ffff:ffff:ffff:ffff:ffff:ffff", + "blocked": true + }, + { + "address": "3fff:1000::", + "blocked": false + }, + { + "address": "5eff:ffff:ffff:ffff:ffff:ffff:ffff:ffff", + "blocked": false + }, + { + "address": "5f00::", + "blocked": false + }, + { + "address": "5f00:ffff:ffff:ffff:ffff:ffff:ffff:ffff", + "blocked": false + }, + { + "address": "5f01::", + "blocked": false + }, + { + "address": "fbff:ffff:ffff:ffff:ffff:ffff:ffff:ffff", + "blocked": false + }, + { + "address": "fc00::", + "blocked": true + }, + { + "address": "fdff:ffff:ffff:ffff:ffff:ffff:ffff:ffff", + "blocked": true + }, + { + "address": "fe00::", + "blocked": false + }, + { + "address": "fe7f:ffff:ffff:ffff:ffff:ffff:ffff:ffff", + "blocked": false + }, + { + "address": "fe80::", + "blocked": true + }, + { + "address": "febf:ffff:ffff:ffff:ffff:ffff:ffff:ffff", + "blocked": true + }, + { + "address": "fec0::", + "blocked": false + }, + { + "address": "feff:ffff:ffff:ffff:ffff:ffff:ffff:ffff", + "blocked": false + }, + { + "address": "ff00::", + "blocked": true + }, + { + "address": "ffff:ffff:ffff:ffff:ffff:ffff:ffff:ffff", + "blocked": true + }, + { + "address": "8.8.8.8", + "blocked": false + }, + { + "address": "1.1.1.1", + "blocked": false + }, + { + "address": "100.128.0.1", + "blocked": false + }, + { + "address": "2606:4700::1111", + "blocked": false + }, + { + "address": "2001:200::1", + "blocked": false + }, + { + "address": "2001:4860:4860::8888", + "blocked": false + }, + { + "address": "2002:c000:204::1", + "blocked": true + }, + { + "address": "2002:0a00:1::", + "blocked": true + }, + { + "address": "2002:808:808::1", + "blocked": true + }, + { + "address": "::ffff:127.0.0.1", + "blocked": true + }, + { + "address": "::ffff:168.63.129.16", + "blocked": true + }, + { + "address": "::ffff:10.0.0.1", + "blocked": true + }, + { + "address": "::ffff:8.8.8.8", + "blocked": false + }, + { + "address": "::ffff:169.254.169.254", + "blocked": true + }, + { + "address": "::127.0.0.1", + "blocked": false + }, + { + "address": "::8.8.8.8", + "blocked": false + }, + { + "address": "64:ff9b::808:808", + "blocked": false + }, + { + "address": "64:ff9b::a00:1", + "blocked": false + } + ] +} diff --git a/litellm-rust/crates/http/scripts/generate_blocked_ips.py b/litellm-rust/crates/http/scripts/generate_blocked_ips.py new file mode 100644 index 00000000000..8b708f4d0ae --- /dev/null +++ b/litellm-rust/crates/http/scripts/generate_blocked_ips.py @@ -0,0 +1,121 @@ +"""Regenerate generated/blocked_ips.json: what the Python SSRF policy says for each probe. + + uv run --no-sync python litellm-rust/crates/http/scripts/generate_blocked_ips.py \ + > litellm-rust/crates/http/generated/blocked_ips.json + +Each row records whether ``litellm.litellm_core_utils.url_utils._is_blocked_ip`` blocks the +address under the interpreter named in the header. The verdicts depend on that interpreter's +``ipaddress`` tables, so the file is regenerated, never hand-edited. +""" + +import json +import platform +import sys +from ipaddress import ip_network +from typing import Final + +from litellm.litellm_core_utils.url_utils import _is_blocked_ip + +# IANA special-purpose ranges plus the cloud-fabric exceptions, probed at both edges and one +# address past each edge so a prefix length that is off by one shows up. +NETWORKS: Final = ( + "0.0.0.0/8", + "10.0.0.0/8", + "100.64.0.0/10", + "127.0.0.0/8", + "168.63.129.16/32", + "169.254.0.0/16", + "172.16.0.0/12", + "192.0.0.0/24", + "192.0.0.9/32", + "192.0.0.10/32", + "192.0.0.170/31", + "192.0.2.0/24", + "192.31.196.0/24", + "192.52.193.0/24", + "192.88.99.0/24", + "192.168.0.0/16", + "192.175.48.0/24", + "198.18.0.0/15", + "198.51.100.0/24", + "203.0.113.0/24", + "224.0.0.0/4", + "240.0.0.0/4", + "255.255.255.255/32", + "::/128", + "::1/128", + "::ffff:0:0/96", + "64:ff9b::/96", + "64:ff9b:1::/48", + "100::/64", + "2001::/23", + "2001::/32", + "2001:1::1/128", + "2001:1::2/128", + "2001:2::/48", + "2001:3::/32", + "2001:4:112::/48", + "2001:10::/28", + "2001:20::/28", + "2001:30::/28", + "2001:db8::/32", + "2002::/16", + "3fff::/20", + "5f00::/16", + "fc00::/7", + "fe80::/10", + "fec0::/10", + "ff00::/8", +) + +# Addresses inside the ranges that the module's own tests name, plus mapped and compat forms. +SPOT_CHECKS: Final = ( + "8.8.8.8", + "1.1.1.1", + "168.63.129.17", + "100.128.0.1", + "223.255.255.255", + "2606:4700::1111", + "2001:200::1", + "2001:4860:4860::8888", + "2002:c000:204::1", + "2002:0a00:1::", + "2002:808:808::1", + "::ffff:127.0.0.1", + "::ffff:168.63.129.16", + "::ffff:10.0.0.1", + "::ffff:8.8.8.8", + "::ffff:169.254.169.254", + "::127.0.0.1", + "::8.8.8.8", + "64:ff9b::808:808", + "64:ff9b::a00:1", +) + + +def _edges(network: str) -> tuple[str, ...]: + net: Final = ip_network(network) + first: Final = net.network_address + last: Final = net.broadcast_address + below: Final = first - 1 if int(first) > 0 else None + above: Final = last + 1 if int(last) < 2**net.max_prefixlen - 1 else None + return tuple(str(address) for address in (below, first, last, above) if address is not None) + + +def _probes() -> tuple[str, ...]: + edges: Final = tuple(address for network in NETWORKS for address in _edges(network)) + return tuple(dict.fromkeys(edges + SPOT_CHECKS)) + + +def main() -> None: + rows: Final = tuple({"address": address, "blocked": _is_blocked_ip(address)} for address in _probes()) + json.dump( + {"python": platform.python_version(), "rows": rows}, + sys.stdout, + indent=2, + ) + sys.stdout.write("\n") + + +if __name__ == "__main__": + main() diff --git a/litellm-rust/crates/http/src/media.rs b/litellm-rust/crates/http/src/media.rs index 22a881831cf..349a6f26a51 100644 --- a/litellm-rust/crates/http/src/media.rs +++ b/litellm-rust/crates/http/src/media.rs @@ -1,7 +1,7 @@ use std::{ future::Future, io, - net::{IpAddr, SocketAddr}, + net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr}, pin::Pin, sync::Arc, time::Duration, @@ -287,38 +287,59 @@ fn validate_addresses(addresses: &[SocketAddr]) -> Result<(), Error> { Ok(()) } +/// Ranges the IANA special-purpose registries mark as not globally reachable, plus multicast +/// and the Azure Wire Server. A superset of what `_is_blocked_ip` in +/// `litellm/litellm_core_utils/url_utils.py` rejects, pinned by `generated/blocked_ips.json`. +const BLOCKED_V4: [(Ipv4Addr, u32); 15] = [ + (Ipv4Addr::new(0, 0, 0, 0), 8), + (Ipv4Addr::new(10, 0, 0, 0), 8), + (Ipv4Addr::new(100, 64, 0, 0), 10), + (Ipv4Addr::new(127, 0, 0, 0), 8), + (Ipv4Addr::new(168, 63, 129, 16), 32), + (Ipv4Addr::new(169, 254, 0, 0), 16), + (Ipv4Addr::new(172, 16, 0, 0), 12), + (Ipv4Addr::new(192, 0, 0, 0), 24), + (Ipv4Addr::new(192, 0, 2, 0), 24), + (Ipv4Addr::new(192, 88, 99, 0), 24), + (Ipv4Addr::new(192, 168, 0, 0), 16), + (Ipv4Addr::new(198, 18, 0, 0), 15), + (Ipv4Addr::new(198, 51, 100, 0), 24), + (Ipv4Addr::new(203, 0, 113, 0), 24), + (Ipv4Addr::new(224, 0, 0, 0), 3), +]; + +const BLOCKED_V6: [(Ipv6Addr, u32); 13] = [ + (Ipv6Addr::UNSPECIFIED, 128), + (Ipv6Addr::LOCALHOST, 128), + (Ipv6Addr::new(0x64, 0xff9b, 1, 0, 0, 0, 0, 0), 48), + (Ipv6Addr::new(0x100, 0, 0, 0, 0, 0, 0, 0), 64), + (Ipv6Addr::new(0x2001, 0, 0, 0, 0, 0, 0, 0), 23), + (Ipv6Addr::new(0x2001, 0xdb8, 0, 0, 0, 0, 0, 0), 32), + (Ipv6Addr::new(0x2002, 0, 0, 0, 0, 0, 0, 0), 16), + (Ipv6Addr::new(0x3fff, 0, 0, 0, 0, 0, 0, 0), 20), + (Ipv6Addr::new(0x5f00, 0, 0, 0, 0, 0, 0, 0), 16), + (Ipv6Addr::new(0xfc00, 0, 0, 0, 0, 0, 0, 0), 7), + (Ipv6Addr::new(0xfe80, 0, 0, 0, 0, 0, 0, 0), 10), + (Ipv6Addr::new(0xfec0, 0, 0, 0, 0, 0, 0, 0), 10), + (Ipv6Addr::new(0xff00, 0, 0, 0, 0, 0, 0, 0), 8), +]; + +fn in_network(address: u128, network: u128, prefix: u32) -> bool { + (address ^ network).checked_shr(BITS - prefix).unwrap_or(0) == 0 +} + fn is_blocked_ip(ip: IpAddr) -> bool { match ip { - IpAddr::V4(ip) => { - let [first, second, third, _] = ip.octets(); - first == 0 - || first == 10 - || first == 127 - || (first == 100 && (64..=127).contains(&second)) - || (first == 169 && second == 254) - || (first == 172 && (16..=31).contains(&second)) - || (first == 192 && second == 0 && (third == 0 || third == 2)) - || (first == 192 && second == 168) - || (first == 192 && second == 88 && third == 99) - || (first == 198 && (second == 18 || second == 19)) - || (first == 198 && second == 51 && third == 100) - || (first == 203 && second == 0 && third == 113) - || first >= 224 - } + IpAddr::V4(ip) => BLOCKED_V4.iter().any(|(network, prefix)| { + in_network::<32>(u32::from(ip).into(), u32::from(*network).into(), *prefix) + }), IpAddr::V6(ip) => { - let segments = ip.segments(); - ip.is_loopback() - || ip.is_unspecified() - || ip.is_multicast() - || (segments[0] & 0xfe00) == 0xfc00 - || (segments[0] & 0xffc0) == 0xfe80 - || (segments[0] & 0xffc0) == 0xfec0 - || (segments[0] == 0x2001 && segments[1] == 0x0db8) - || ip - .to_ipv4_mapped() - .or_else(|| ip.to_ipv4()) - .map(|ipv4| is_blocked_ip(IpAddr::V4(ipv4))) - .unwrap_or(false) + BLOCKED_V6.iter().any(|(network, prefix)| { + in_network::<128>(u128::from(ip), u128::from(*network), *prefix) + }) || ip + .to_ipv4_mapped() + .or_else(|| ip.to_ipv4()) + .is_some_and(|ipv4| is_blocked_ip(IpAddr::V4(ipv4))) } } } @@ -499,23 +520,162 @@ mod tests { #[case::documentation_v4_first_range("198.51.100.1")] #[case::documentation_v4_second_range("203.0.113.1")] #[case::multicast_v4("224.0.0.1")] + #[case::broadcast_v4("255.255.255.255")] + #[case::ietf_protocol_assignments_v4("192.0.0.9")] + #[case::azure_wire_server("168.63.129.16")] + #[case::unspecified_v6("::")] #[case::loopback_v6("::1")] + #[case::local_use_nat64("64:ff9b:1::1")] + #[case::discard_only_v6("100::1")] + #[case::teredo("2001::1")] + #[case::documentation_v6("2001:db8::1")] + #[case::six_to_four("2002:c000:204::1")] + #[case::documentation_v6_second_range("3fff::1")] + #[case::segment_routing_sids("5f00::1")] #[case::unique_local_v6("fc00::1")] #[case::link_local_v6("fe80::1")] - #[case::documentation_v6("2001:db8::1")] + #[case::site_local_v6("fec0::1")] + #[case::multicast_v6("ff02::1")] #[case::mapped_loopback_v6("::ffff:127.0.0.1")] + #[case::mapped_azure_wire_server("::ffff:168.63.129.16")] fn blocks_non_public_addresses(#[case] address: &str) { assert!(is_blocked_ip(address.parse().expect("valid test address"))); } #[rstest::rstest] #[case::public("8.8.8.8")] + #[case::next_to_azure_wire_server("168.63.129.17")] + #[case::next_to_carrier_grade_nat("100.128.0.1")] + #[case::last_unicast_before_multicast("223.255.255.255")] + #[case::public_v6("2606:4700::1111")] + #[case::above_ietf_protocol_assignments_v6("2001:200::1")] + #[case::mapped_public_v6("::ffff:8.8.8.8")] fn allows_a_public_address(#[case] address: &str) { assert!(!is_blocked_ip( address.parse().expect("valid public address") )); } + #[derive(serde::Deserialize)] + struct BlockedIpFixture { + rows: Vec, + } + + #[derive(serde::Deserialize)] + struct BlockedIpRow { + address: IpAddr, + blocked: bool, + } + + /// Ranges Rust blocks where CPython's `ipaddress` reports global, with the reason. Each + /// entry must still cover a diverging fixture row, so it is deleted once CPython catches up. + const KNOWN_OVER_BLOCKED: &[(&str, &str)] = &[ + ( + "192.88.99.0/24", + "deprecated 6to4 relay anycast, CPython never marked it non-global", + ), + ( + "2001:1::1/128", + "Port Control Protocol anycast, carved out of 2001::/23 by CPython", + ), + ( + "2001:1::2/128", + "TURN anycast, carved out of 2001::/23 by CPython", + ), + ( + "2001:3::/32", + "AMT relays, carved out of 2001::/23 by CPython", + ), + ( + "2001:4:112::/48", + "AS112-v6, carved out of 2001::/23 by CPython", + ), + ( + "2001:20::/28", + "ORCHIDv2, carved out of 2001::/23 by CPython", + ), + ( + "2001:30::/28", + "Drone Remote ID, carved out of 2001::/23 by CPython", + ), + ("5f00::/16", "SRv6 SIDs, newer than CPython's table"), + ( + "fec0::/10", + "deprecated site-local, which CPython only exposes as `is_site_local`", + ), + ( + "::/96", + "deprecated IPv4-compatible addresses, Rust judges the embedded IPv4 address", + ), + ]; + + fn in_cidr(ip: IpAddr, cidr: &str) -> bool { + let (network, prefix) = cidr.split_once('/').expect("cidr carries a prefix length"); + let prefix: u32 = prefix.parse().expect("prefix length is numeric"); + match (ip, network.parse().expect("cidr network parses")) { + (IpAddr::V4(ip), IpAddr::V4(network)) => { + in_network::<32>(u32::from(ip).into(), u32::from(network).into(), prefix) + } + (IpAddr::V6(ip), IpAddr::V6(network)) => { + in_network::<128>(ip.into(), network.into(), prefix) + } + _ => false, + } + } + + #[test] + fn blocks_everything_the_python_policy_blocks() { + let fixture: BlockedIpFixture = serde_json::from_str(include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/generated/blocked_ips.json" + ))) + .expect("blocked_ips.json matches the fixture schema"); + let verdicts: Vec<(IpAddr, bool, bool)> = fixture + .rows + .iter() + .map(|row| (row.address, row.blocked, is_blocked_ip(row.address))) + .collect(); + + let under_blocked: Vec = verdicts + .iter() + .filter(|(_, python, rust)| *python && !*rust) + .map(|(address, ..)| *address) + .collect(); + assert!( + under_blocked.is_empty(), + "Python blocks but Rust allows: {under_blocked:?}" + ); + + let over_blocked: Vec = verdicts + .iter() + .filter(|(_, python, rust)| !*python && *rust) + .map(|(address, ..)| *address) + .collect(); + let unexplained: Vec = over_blocked + .iter() + .copied() + .filter(|address| { + !KNOWN_OVER_BLOCKED + .iter() + .any(|(cidr, _)| in_cidr(*address, cidr)) + }) + .collect(); + assert!( + unexplained.is_empty(), + "Rust blocks but Python allows, missing from KNOWN_OVER_BLOCKED: {unexplained:?}" + ); + + let stale: Vec<&str> = KNOWN_OVER_BLOCKED + .iter() + .filter(|(cidr, _)| !over_blocked.iter().any(|address| in_cidr(*address, cidr))) + .map(|(cidr, _)| *cidr) + .collect(); + assert!( + stale.is_empty(), + "KNOWN_OVER_BLOCKED entries that no longer diverge from Python: {stale:?}" + ); + } + #[tokio::test] async fn fetches_exact_limit_and_normalizes_content_type() { let (url, server) = serve( From 0cd7307b91e4a3edfce544cf33377bc8a0836bdb Mon Sep 17 00:00:00 2001 From: Yujong Lee Date: Sat, 10 Oct 2026 17:39:00 +0000 Subject: [PATCH 2/3] test(rust): read the blocked IP fixture through an rstest once fixture Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- litellm-rust/crates/http/src/media.rs | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/litellm-rust/crates/http/src/media.rs b/litellm-rust/crates/http/src/media.rs index 349a6f26a51..4e778d6fd26 100644 --- a/litellm-rust/crates/http/src/media.rs +++ b/litellm-rust/crates/http/src/media.rs @@ -623,14 +623,19 @@ mod tests { } } - #[test] - fn blocks_everything_the_python_policy_blocks() { - let fixture: BlockedIpFixture = serde_json::from_str(include_str!(concat!( + #[rstest::fixture] + #[once] + fn python_verdicts() -> BlockedIpFixture { + serde_json::from_str(include_str!(concat!( env!("CARGO_MANIFEST_DIR"), "/generated/blocked_ips.json" ))) - .expect("blocked_ips.json matches the fixture schema"); - let verdicts: Vec<(IpAddr, bool, bool)> = fixture + .expect("blocked_ips.json matches the fixture schema") + } + + #[rstest::rstest] + fn blocks_everything_the_python_policy_blocks(python_verdicts: &BlockedIpFixture) { + let verdicts: Vec<(IpAddr, bool, bool)> = python_verdicts .rows .iter() .map(|row| (row.address, row.blocked, is_blocked_ip(row.address))) From 3bc5ac04451f649d44199f5cdf8d1c7ebde7879c Mon Sep 17 00:00:00 2001 From: Yujong Lee Date: Sat, 10 Oct 2026 17:41:46 +0000 Subject: [PATCH 3/3] test(rust): prove a host resolving to a newly blocked address gets no request Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- litellm-rust/crates/http/src/media.rs | 61 ++++++++++++++++++++++----- 1 file changed, 51 insertions(+), 10 deletions(-) diff --git a/litellm-rust/crates/http/src/media.rs b/litellm-rust/crates/http/src/media.rs index 4e778d6fd26..c55759abec0 100644 --- a/litellm-rust/crates/http/src/media.rs +++ b/litellm-rust/crates/http/src/media.rs @@ -454,19 +454,17 @@ mod tests { struct TestAddressResolver { blocked_hosts: HashSet<&'static str>, + blocked_ip: IpAddr, } impl AddressResolver for TestAddressResolver { fn resolve<'a>(&'a self, host: &'a str, port: u16) -> AddressResolution<'a> { - let blocked = self.blocked_hosts.contains(host); - Box::pin(async move { - let ip = if blocked { - IpAddr::from([127, 0, 0, 1]) - } else { - IpAddr::from([8, 8, 8, 8]) - }; - Ok(vec![SocketAddr::new(ip, port)]) - }) + let ip = if self.blocked_hosts.contains(host) { + self.blocked_ip + } else { + IpAddr::from([8, 8, 8, 8]) + }; + Box::pin(async move { Ok(vec![SocketAddr::new(ip, port)]) }) } } @@ -482,13 +480,32 @@ mod tests { blocked_hosts: HashSet<&'static str>, url_policy: UrlPolicy, uses_proxy: bool, + ) -> MediaFetcher { + fetcher_resolving_blocked_hosts_to( + pinned_address, + blocked_hosts, + IpAddr::from([127, 0, 0, 1]), + url_policy, + uses_proxy, + ) + } + + fn fetcher_resolving_blocked_hosts_to( + pinned_address: SocketAddr, + blocked_hosts: HashSet<&'static str>, + blocked_ip: IpAddr, + url_policy: UrlPolicy, + uses_proxy: bool, ) -> MediaFetcher { let direct = Resolution::from(&HttpSettings::default()).config; MediaFetcher::with_resolution( &HttpClientPool::new(Arc::new(LoopbackDnsResolver(pinned_address))), &direct, url_policy, - Arc::new(TestAddressResolver { blocked_hosts }), + Arc::new(TestAddressResolver { + blocked_hosts, + blocked_ip, + }), Arc::new(move |_| uses_proxy), ) .expect("test fetcher builds") @@ -747,6 +764,30 @@ mod tests { assert_eq!(media.bytes, b"ok"); } + #[rstest::rstest] + #[case::six_to_four("2002:c000:204::1")] + #[case::ietf_protocol_assignments_v4("192.0.0.9")] + #[case::mapped_azure_wire_server("::ffff:168.63.129.16")] + #[tokio::test] + async fn host_resolving_to_a_newly_blocked_address_receives_no_request( + #[case] resolved: IpAddr, + ) { + let (url, server, address) = serve_named("blocked.test", vec![OK_RESPONSE]).await; + let error = fetcher_resolving_blocked_hosts_to( + address, + HashSet::from(["blocked.test"]), + resolved, + UrlPolicy::default(), + false, + ) + .fetch(url, policy(10, 0)) + .await + .expect_err("blocked destination is rejected"); + assert!(matches!(error, Error::BlockedUrl)); + assert!(!server.is_finished(), "server must not have seen a request"); + server.abort(); + } + #[tokio::test] async fn blocks_redirected_private_destination_before_second_request() { let (url, server, address) = serve_named(