Skip to content

Commit d136b4c

Browse files
authored
fix(security): require is_global in the SSRF guard (GHSA-cx77-j6h8-3382) (#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.
1 parent 9365b94 commit d136b4c

6 files changed

Lines changed: 133 additions & 11 deletions

File tree

‎SECURITY.md‎

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -301,3 +301,40 @@ is what makes an unsolicited callback useless.
301301
have been open, reconnect the Meta account so a fresh token is issued.
302302
- Credited to [@Gal3m](https://github.com/Gal3m) and
303303
[@mohammad228](https://github.com/mohammad228).
304+
305+
### GHSA-cx77-j6h8-3382 — SSRF guard allowed shared address space (`100.64.0.0/10`)
306+
307+
- **Severity:** Medium
308+
- **Affected versions:** `>= 1.0.115, <= 1.0.124` — i.e. every version carrying
309+
the original SSRF guard.
310+
- **Fixed in:** `1.0.125`
311+
- **Affected configurations:** Deployments where non-public services live in
312+
`100.64.0.0/10` — GKE and other Kubernetes clusters default pod/service CIDRs
313+
into that range, as do cloud internal endpoints and ISP CGNAT — and where a
314+
caller can drive `upload_ad_image` or the image-viewing tools (including an
315+
agent acting on injected instructions).
316+
317+
**What went wrong.** `validate_public_url` classified a resolved address with a
318+
category denylist: `is_private or is_loopback or is_link_local or is_reserved or
319+
is_multicast or is_unspecified`. `100.64.0.0/10` (RFC 6598 shared address space)
320+
satisfies none of those — it is `is_global == False` but neither private nor
321+
reserved — so a URL resolving into it passed the guard and was fetched
322+
server-side, and the redirect re-validation hook allowed redirects that stayed
323+
inside the range. Loopback, RFC 1918 and link-local (including the cloud
324+
metadata endpoint) remained blocked, so the exposure was a partial internal-range
325+
SSRF confined to shared address space.
326+
327+
**Fix.** The guard now tests positively for `ip.is_global` and rejects anything
328+
else, so non-public ranges no longer have to be enumerated to be blocked. The
329+
original category checks are kept alongside it, plus an explicit list of
330+
non-public networks (`100.64.0.0/10`, `192.0.0.0/24`, `198.18.0.0/15`, the RFC
331+
5737 TEST-NETs, `240.0.0.0/4`, `2001:db8::/32`, `64:ff9b:1::/48`), because
332+
`is_global`'s backing table has shifted across CPython patch releases. IPv4-mapped
333+
IPv6 addresses are still unwrapped first, so `::ffff:100.64.1.1` is rejected too.
334+
335+
**Action for operators.**
336+
- Upgrade to `1.0.125` or later.
337+
- If you ran an earlier version where `100.64.0.0/10` reaches internal services,
338+
review outbound request logs for fetches into that range.
339+
- Credited to [@Gal3m](https://github.com/Gal3m) and
340+
[@mohammad228](https://github.com/mohammad228).

‎meta_ads_mcp/__init__.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66

77
from meta_ads_mcp.core.server import main
88

9-
__version__ = "1.0.124"
9+
__version__ = "1.0.125"
1010

1111
__all__ = [
1212
'get_ad_accounts',

‎meta_ads_mcp/core/utils.py‎

Lines changed: 37 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -164,26 +164,54 @@ class BlockedURLError(Exception):
164164

165165
_ALLOWED_URL_SCHEMES = ("http", "https")
166166

167+
# Non-public ranges named explicitly, because `is_global`'s backing table has
168+
# changed across CPython patch releases and a category denylist alone missed
169+
# them (GHSA-cx77-j6h8-3382: 100.64.0.0/10 is neither private nor reserved, so
170+
# it passed the original check while hosting internal services on the
171+
# deployments that use it — GKE pod/service CIDRs, cloud internal endpoints,
172+
# ISP CGNAT).
173+
_BLOCKED_NETWORKS = tuple(
174+
ipaddress.ip_network(cidr)
175+
for cidr in (
176+
"100.64.0.0/10", # RFC 6598 shared address space (CGNAT)
177+
"192.0.0.0/24", # RFC 6890 IETF protocol assignments
178+
"192.0.2.0/24", # RFC 5737 TEST-NET-1
179+
"198.18.0.0/15", # RFC 2544 benchmarking
180+
"198.51.100.0/24", # RFC 5737 TEST-NET-2
181+
"203.0.113.0/24", # RFC 5737 TEST-NET-3
182+
"240.0.0.0/4", # RFC 1112 reserved, incl. 255.255.255.255
183+
"2001:db8::/32", # RFC 3849 documentation
184+
"64:ff9b:1::/48", # RFC 8215 local-use IPv4/IPv6 translation
185+
)
186+
)
187+
167188

168189
def _ip_is_disallowed(ip) -> bool:
169190
"""Return True if `ip` is not a public, routable address.
170191
171-
Blocks private, loopback, link-local (incl. 169.254.169.254 cloud
172-
metadata), reserved, multicast, and unspecified addresses. IPv4-mapped
173-
IPv6 addresses (e.g. ::ffff:127.0.0.1) are unwrapped first so they can't
174-
be used to smuggle a private IPv4 target past the check.
192+
The primary test is positive — an address must be `is_global` — so ranges
193+
that are simply not public (shared address space, benchmarking, TEST-NETs)
194+
are rejected without having to be enumerated. The category checks and the
195+
explicit network list are kept alongside it as belt and braces.
196+
197+
IPv4-mapped IPv6 addresses (e.g. ::ffff:127.0.0.1) are unwrapped first so
198+
they cannot smuggle a non-public IPv4 target past the check.
175199
"""
176200
mapped = getattr(ip, "ipv4_mapped", None)
177201
if mapped is not None:
178202
ip = mapped
179-
return (
203+
if (
180204
ip.is_private
181205
or ip.is_loopback
182206
or ip.is_link_local
183207
or ip.is_reserved
184208
or ip.is_multicast
185209
or ip.is_unspecified
186-
)
210+
):
211+
return True
212+
if not ip.is_global:
213+
return True
214+
return any(ip in network for network in _BLOCKED_NETWORKS)
187215

188216

189217
def validate_public_url(url: str) -> None:
@@ -230,8 +258,9 @@ def validate_public_url(url: str) -> None:
230258
if _ip_is_disallowed(ip):
231259
raise BlockedURLError(
232260
f"Refusing to fetch '{host}': it resolves to a non-public address "
233-
f"({ip}). Private, loopback, link-local, and cloud-metadata "
234-
"addresses are blocked to prevent server-side request forgery."
261+
f"({ip}). Private, loopback, link-local, shared (CGNAT), reserved "
262+
"and cloud-metadata addresses are blocked to prevent server-side "
263+
"request forgery."
235264
)
236265

237266

‎pyproject.toml‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
44

55
[project]
66
name = "meta-ads-mcp"
7-
version = "1.0.124"
7+
version = "1.0.125"
88
description = "Model Context Protocol (MCP) server for Meta Ads - Use Remote MCP at pipeboard.co for easiest setup"
99
readme = "README.md"
1010
requires-python = ">=3.10"

‎server.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
"$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json",
33
"name": "co.pipeboard/meta-ads-mcp",
44
"description": "Facebook / Meta Ads automation with AI: analyze performance, test creatives, optimize spend.",
5-
"version": "1.0.124",
5+
"version": "1.0.125",
66
"remotes": [
77
{
88
"type": "streamable-http",

‎tests/test_ssrf_url_validation.py‎

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,17 @@
55
caller-supplied URL server-side, so the URL must be restricted to public
66
http(s) targets before any connection is opened.
77
8+
Also covers GHSA-cx77-j6h8-3382: the first fix classified addresses with a
9+
category denylist, which let 100.64.0.0/10 (RFC 6598 shared address space, used
10+
for GKE pod/service CIDRs and ISP CGNAT) through because it is neither private
11+
nor reserved. The guard now requires `is_global`.
12+
813
These tests use literal IPs for the block/allow matrix so they do not depend on
914
network DNS; only the "localhost" case exercises real name resolution (which is
1015
universally available).
1116
"""
1217

18+
import ipaddress
1319
import json
1420
from unittest.mock import AsyncMock, patch
1521

@@ -18,6 +24,7 @@
1824

1925
from meta_ads_mcp.core.utils import (
2026
BlockedURLError,
27+
_ip_is_disallowed,
2128
validate_public_url,
2229
download_image,
2330
try_multiple_download_methods,
@@ -41,6 +48,18 @@
4148
"http://[fe80::1]/", # IPv6 link-local
4249
"http://[fc00::1]/", # IPv6 unique-local (private)
4350
"https://169.254.169.254/", # https also blocked
51+
# GHSA-cx77-j6h8-3382 — non-public, but neither private nor reserved, so a
52+
# category denylist let these through.
53+
"http://100.64.1.1/internal", # RFC 6598 shared address space
54+
"http://100.127.255.254/", # top of the shared range
55+
"http://[::ffff:100.64.1.1]/", # IPv4-mapped shared address
56+
"http://192.0.0.1/", # IETF protocol assignments
57+
"http://198.18.0.5/", # benchmarking range
58+
"http://192.0.2.9/", # TEST-NET-1
59+
"http://198.51.100.9/", # TEST-NET-2
60+
"http://203.0.113.9/", # TEST-NET-3
61+
"http://240.0.0.1/", # reserved (class E)
62+
"http://[2001:db8::1]/", # IPv6 documentation range
4463
]
4564

4665

@@ -93,6 +112,43 @@ def test_validate_public_url_allows_public_ip_literals(url):
93112
validate_public_url(url)
94113

95114

115+
# --- GHSA-cx77-j6h8-3382: shared address space -------------------------------
116+
117+
@pytest.mark.parametrize(
118+
"address",
119+
["100.64.0.0", "100.64.1.1", "100.127.255.254", "100.127.255.255"],
120+
)
121+
def test_shared_address_space_is_not_public(address):
122+
"""100.64.0.0/10 is is_global == False but is_private == False, which is
123+
exactly the gap the category denylist had."""
124+
ip = ipaddress.ip_address(address)
125+
126+
assert not ip.is_private, "test would pass for the wrong reason"
127+
assert _ip_is_disallowed(ip)
128+
129+
130+
@pytest.mark.parametrize("address", ["100.63.255.255", "100.128.0.0", "8.8.8.8"])
131+
def test_addresses_outside_the_shared_range_stay_reachable(address):
132+
"""The bounds matter: 100.64.0.0/10 is not all of 100.0.0.0/8."""
133+
assert not _ip_is_disallowed(ipaddress.ip_address(address))
134+
135+
136+
async def test_redirect_into_shared_address_space_is_blocked():
137+
def handler(request: httpx.Request) -> httpx.Response:
138+
if request.url.host == "8.8.8.8":
139+
return httpx.Response(302, headers={"Location": "http://100.64.1.1/internal"})
140+
return httpx.Response(200, content=b"SHOULD-NOT-BE-FETCHED")
141+
142+
transport = httpx.MockTransport(handler)
143+
async with httpx.AsyncClient(
144+
transport=transport,
145+
follow_redirects=True,
146+
event_hooks={"request": [_ssrf_guard_request_hook]},
147+
) as client:
148+
with pytest.raises(BlockedURLError):
149+
await client.get("https://8.8.8.8/start")
150+
151+
96152
# --- request event hook (covers redirect hops) ------------------------------
97153

98154
async def test_ssrf_guard_request_hook_blocks_internal():

0 commit comments

Comments
 (0)