Skip to content

fix(security): require is_global in the SSRF guard (GHSA-cx77-j6h8-3382) - #165

Merged
nictuku merged 1 commit into
mainfrom
fix/ghsa-cx77-ssrf-is-global
Sep 22, 2026
Merged

nictuku merged 1 commit into
mainfrom
fix/ghsa-cx77-ssrf-is-global

Conversation

@nictuku

@nictuku nictuku commented Sep 22, 2026

Copy link
Copy Markdown
Collaborator

Fixes GHSA-cx77-j6h8-3382, reported by @Gal3m and @mohammad228. Follow-up to the SSRF guard shipped in 1.0.115 (GHSA-45gf-fjxp-cjpq).

The gap

validate_public_url classified a resolved address with a category denylist — is_private or is_loopback or is_link_local or is_reserved or is_multicast or is_unspecified. 100.64.0.0/10 (RFC 6598 shared address space) satisfies none of them:

100.64.1.1        global=False  private=False  reserved=False
169.254.169.254   global=False  private=True   reserved=False

So a URL resolving into that range passed the guard and was fetched server-side, and the redirect re-validation hook allowed hops that stayed inside it. That range is not exotic — GKE and other Kubernetes clusters default pod/service CIDRs into it, as do cloud internal endpoints and ISP CGNAT. Loopback, RFC 1918 and link-local (including cloud metadata) were always blocked, so this was a partial internal-range SSRF confined to shared address space.

The fix

_ip_is_disallowed now tests positively for ip.is_global and rejects everything else, so non-public ranges no longer have to be enumerated to be caught. Two things are kept alongside it rather than replaced:

  • the original category checks, which cost nothing and cannot produce a false negative for a genuinely public address;
  • an explicit _BLOCKED_NETWORKS list (100.64.0.0/10, 192.0.0.0/24, 198.18.0.0/15, the RFC 5737 TEST-NETs, 240.0.0.0/4, 2001:db8::/32, 64:ff9b:1::/48) — is_global's backing table has shifted across CPython patch releases, and this guard should not depend on which patch release the operator runs.

IPv4-mapped IPv6 addresses are still unwrapped first, so ::ffff:100.64.1.1 is rejected too.

Verified against the advisory's PoC cases — all now blocked, public literals still allowed:

blocked  http://100.64.1.1/internal        blocked  http://203.0.113.9/
blocked  http://100.127.255.254/           blocked  http://240.0.0.1/
blocked  http://198.18.0.5/                blocked  http://[::ffff:100.64.1.1]/
ALLOWED  http://8.8.8.8/x                  ALLOWED  https://1.1.1.1/y

Tests

tests/test_ssrf_url_validation.py gains 10 blocked URLs (the shared range, its IPv4-mapped form, protocol assignments, benchmarking, all three TEST-NETs, class E, IPv6 documentation) plus:

  • a test asserting 100.64.x.x is is_private == False before asserting it is blocked, so it cannot pass for the wrong reason;
  • boundary tests that 100.63.255.255 and 100.128.0.0 stay reachable — the range is not all of 100.0.0.0/8;
  • a redirect test that a public URL 302-ing into 100.64.1.1 is stopped at the hop.

Full suite: 576 passed, 9 skipped. Bumped to 1.0.125 with a SECURITY.md entry.

validate_public_url classified resolved addresses with a category denylist, and 100.64.0.0/10 (RFC 6598 shared address space) satisfies none of those categories: it is is_global False but neither private nor reserved. A URL resolving into it passed the guard and was fetched server-side, and the redirect hook allowed hops that stayed inside the range. That range hosts internal services on GKE and other Kubernetes clusters, cloud internal endpoints and ISP CGNAT.

The guard now tests positively for is_global, keeps the category checks alongside it, and names the non-public networks explicitly because is_global's backing table has shifted across CPython patch releases. IPv4-mapped addresses are still unwrapped first.

Bumps to 1.0.125 and documents the advisory in SECURITY.md.
@nictuku
nictuku merged commit d136b4c into main Sep 22, 2026
4 checks passed
PM-Labs pushed a commit to pmlabs-org/mcp-meta-ads that referenced this pull request Oct 11, 2026
…) (pipeboard-co#165)

validate_public_url classified resolved addresses with a category denylist, and 100.64.0.0/10 (RFC 6598 shared address space) satisfies none of those categories: it is is_global False but neither private nor reserved. A URL resolving into it passed the guard and was fetched server-side, and the redirect hook allowed hops that stayed inside the range. That range hosts internal services on GKE and other Kubernetes clusters, cloud internal endpoints and ISP CGNAT.

The guard now tests positively for is_global, keeps the category checks alongside it, and names the non-public networks explicitly because is_global's backing table has shifted across CPython patch releases. IPv4-mapped addresses are still unwrapped first.

Bumps to 1.0.125 and documents the advisory in SECURITY.md.
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.

1 participant