fix(security): block CGNAT range in SSRF connection guard (#5644)#5646
fix(security): block CGNAT range in SSRF connection guard (#5644)#5646Bharath-970 wants to merge 1 commit into
Conversation
make_safe_connect() rejects outbound connections to private, loopback, and link-local IPs (added for CVE-2025-54381 / GHSA-mrmq-3q62-6cc8), but Python's ipaddress module does not classify the RFC 6598 CGNAT range (100.64.0.0/10) as any of those, so it passed through unblocked. This range is used for carrier-grade NAT and, in several cloud environments, internal load balancers and service endpoints reachable from the server. Extend the check with is_reserved and an explicit CGNAT network check (guarded to IPv4 to avoid a version-mismatch error against the IPv6-only private ranges). Extract the predicate into is_unsafe_address() so the security-critical logic is unit-testable without patching uvloop's event loop, and add regression tests covering the CGNAT boundary and other blocked/allowed ranges (IPv4 and IPv6).
eeshsaxena
left a comment
There was a problem hiding this comment.
This is a solid fix for #5644 - factoring out is_unsafe_address, adding is_reserved, and covering 100.64.0.0/10 all look right, and I confirmed the core case: is_unsafe_address(ip_address("100.64.1.1")) is now True while 8.8.8.8 stays allowed.
One gap I think is worth closing while you're here: the CGNAT check is gated on ip.version == 4, so it misses the IPv4-mapped IPv6 form of the same address. On CPython, the existing is_private / is_loopback / is_link_local checks do transparently see through a mapped address, so those ranges are covered in both forms:
::ffff:127.0.0.1 is_loopback=True -> blocked (good)
::ffff:192.168.1.1 is_private=True -> blocked (good)
::ffff:100.64.1.1 is_private=False, version=6 -> is_unsafe_address = False (bypasses the new CGNAT block)
So the new CGNAT protection ends up weaker than the sibling checks specifically for the mapped form. Since the threat model here is an attacker-supplied URL, http://[::ffff:100.64.1.1]/ would reach the CGNAT target that the direct http://100.64.1.1/ form now blocks.
A small normalization fixes it - resolve the mapped address before the range check, e.g.:
v4 = ip if ip.version == 4 else ip.ipv4_mapped
return bool(
ip.is_private
or ip.is_loopback
or ip.is_link_local
or ip.is_reserved
or (v4 is not None and v4 in _CGNAT_NETWORK)
)It'd also be worth adding ::ffff:100.64.1.1 to the blocked-address test cases alongside 100.64.1.1, so the mapped form is pinned.
To be clear this is narrow (only CGNAT, only the mapped form - all the standard private ranges are already handled), so it doesn't detract from the PR; just flagging it so the new guard is as strong as the ones around it. Deferring to the maintainers, who know the resolution path better than I do.
|
Nice fix — the CGNAT range is an easy one to miss. One completeness note in the same class, in case it's useful before this lands: The CGNAT clause here is IPv4-gated ( Verified against this PR's code ( Reproduces on CPython ≥ 3.10 (3.9 happens to block it, but only incidentally — it marks all of Smallest fix I found is to canonicalize the mapped form before the existing checks, so # in is_unsafe_address(), before the existing checks
if ip.version == 6 and ip.ipv4_mapped is not None:
ip = ip.ipv4_mappedI've got this together with regression tests (mapped CGNAT + mapped loopback/link-local blocked; (The analysis here was AI-assisted; every claim above was verified against the imported guard and the repo's own test suite before posting. Noting it since I didn't see an AI-disclosure policy in CONTRIBUTING — happy to follow whatever you use.) |
Closes #5644.
Problem
make_safe_connect()insrc/bentoml/_internal/utils/uri.pyblocks outbound connections to private, loopback, and link-local IPs (added for CVE-2025-54381 / GHSA-mrmq-3q62-6cc8). Python'sipaddressmodule does not classify the RFC 6598 Shared Address Space (CGNAT,100.64.0.0/10) as private, loopback, or link-local, so those addresses pass the check unblocked:CGNAT addresses are used for carrier-grade NAT and, in several cloud environments, for internal load balancers and service endpoints reachable from the server — so this is a real SSRF gap for requests to user-supplied URLs (e.g. file/image inputs).
Fix
is_reservedand an explicit check against the100.64.0.0/10network (guarded to IPv4 to avoid a version-mismatch error when comparing against an IPv6 address).is_unsafe_address()function so the security-critical logic is unit-testable directly, without needing to patch uvloop's event loop.Test
Added
test_is_unsafe_address_blocks_internal_ranges/test_is_unsafe_address_allows_public_addressesintests/unit/_internal/utils/test_uri.py, covering the CGNAT boundary (100.63.255.255allowed,100.64.0.1/100.127.255.255blocked,100.128.0.1allowed) plus existing private/loopback/link-local/reserved ranges and IPv6.Verified as a true regression guard: reverting the fix fails the new CGNAT test cases; restoring it passes all 16 tests in the file.