Skip to content

fix(security): block CGNAT range in SSRF connection guard (#5644)#5646

Open
Bharath-970 wants to merge 1 commit into
bentoml:mainfrom
Bharath-970:fix/ssrf-cgnat-blocklist
Open

fix(security): block CGNAT range in SSRF connection guard (#5644)#5646
Bharath-970 wants to merge 1 commit into
bentoml:mainfrom
Bharath-970:fix/ssrf-cgnat-blocklist

Conversation

@Bharath-970

Copy link
Copy Markdown

Closes #5644.

Problem

make_safe_connect() in src/bentoml/_internal/utils/uri.py blocks outbound connections to private, loopback, and link-local IPs (added for CVE-2025-54381 / GHSA-mrmq-3q62-6cc8). Python's ipaddress module 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:

>>> import ipaddress
>>> ip = ipaddress.ip_address("100.64.1.1")
>>> ip.is_private, ip.is_loopback, ip.is_link_local, ip.is_reserved
(False, False, False, False)

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

  • Extend the guard with is_reserved and an explicit check against the 100.64.0.0/10 network (guarded to IPv4 to avoid a version-mismatch error when comparing against an IPv6 address).
  • Extract the predicate into a small 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_addresses in tests/unit/_internal/utils/test_uri.py, covering the CGNAT boundary (100.63.255.255 allowed, 100.64.0.1/100.127.255.255 blocked, 100.128.0.1 allowed) 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.

tests/unit/_internal/utils/test_uri.py ................  16 passed

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).
@Bharath-970
Bharath-970 requested a review from a team as a code owner July 1, 2026 11:58
@Bharath-970
Bharath-970 requested review from jianshen92 and removed request for a team July 1, 2026 11:58

@eeshsaxena eeshsaxena left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@zelinewang

Copy link
Copy Markdown

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 (ip.version == 4 and ip in _CGNAT_NETWORK), so the same CGNAT host written in IPv4-mapped IPv6 notation slips past it. ::ffff:100.64.1.1 is version == 6, so the clause is skipped, and on CPython ≥ 3.10 ipaddress delegates is_private / is_reserved / is_loopback / is_link_local to the embedded IPv4 — all False for a CGNAT address — so is_unsafe_address() returns False and the guard permits it.

Verified against this PR's code (f9934b9), no DB/network needed:

100.64.1.1               blocked=True    # bare CGNAT — this PR blocks it
::ffff:100.64.1.1        blocked=False   # mapped CGNAT — PERMITTED (bypass)
::ffff:100.127.255.255   blocked=False   # PERMITTED (bypass)

Reproduces on CPython ≥ 3.10 (3.9 happens to block it, but only incidentally — it marks all of ::ffff:0:0/96 reserved). The other mapped internal forms (::ffff:127.0.0.1, ::ffff:169.254.169.254, …) are already caught by that is_* delegation on 3.10+ — CGNAT is the one that leaks, precisely because it rides on the manual _CGNAT_NETWORK list rather than on an is_* property.

Smallest fix I found is to canonicalize the mapped form before the existing checks, so ::ffff:<x> is classified as <x> uniformly (and it stays robust if CPython's mapped-address classification shifts again):

# in is_unsafe_address(), before the existing checks
if ip.version == 6 and ip.ipv4_mapped is not None:
    ip = ip.ipv4_mapped

I've got this together with regression tests (mapped CGNAT + mapped loopback/link-local blocked; ::ffff:8.8.8.8 still allowed) as a ~9-line change on top of this branch — test_uri.py passes 21/21, ruff-clean. Happy to send it as a PR onto this branch, or as a follow-up once this merges — whichever you'd prefer.

(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.)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

SSRF blocklist bypass via CGNAT range (100.64.0.0/10)

3 participants