Skip to content

Commit 7d99263

Browse files
authored
fix(security): block SSRF in upload_ad_image image_url fetch (GHSA-45gf-fjxp-cjpq) (1.0.115) (#138)
upload_ad_image and the image-viewing tools fetched a caller-supplied URL server-side with follow_redirects enabled and no host/IP validation, allowing the server to reach loopback, private-network, and cloud-metadata addresses. - Add validate_public_url(): allow only http/https and reject hosts resolving to private/loopback/link-local/reserved/multicast/unspecified addresses (unwraps IPv4-mapped IPv6). - Re-validate every redirect hop via an httpx request event hook on all outbound image fetches. - download_image returns None for blocked URLs; try_multiple_download_methods raises so upload_ad_image surfaces a clear error. - Add tests (tests/test_ssrf_url_validation.py); document advisory in SECURITY.md; bump to 1.0.115. Fixes GHSA-45gf-fjxp-cjpq.
1 parent 95e8527 commit 7d99263

7 files changed

Lines changed: 354 additions & 17 deletions

File tree

‎.email-allowlist‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,4 +13,5 @@ info@pipeboard.co
1313
# pytest decorator false-positive: @pytest.mark.asyncio matches the email regex
1414
# because the diff +@pytest.mark.asyncio has + in the character class
1515
pytest.mark.asyncio
16+
pytest.mark.parametrize
1617
pytest.fixture

‎SECURITY.md‎

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,42 @@ vulnerabilities.
99

1010
## Advisories
1111

12+
### GHSA-45gf-fjxp-cjpq — Server-Side Request Forgery (SSRF) in `upload_ad_image` via unrestricted `image_url` fetch
13+
14+
- **Severity:** High (CVSS 3.1 8.3 — `AV:N/AC:L/PR:N/UI:N/S:C/C:L/I:L/A:L`)
15+
- **Affected versions:** `<= 1.0.114` when run with `--transport streamable-http`.
16+
- **Fixed in:** `1.0.115`
17+
- **Affected configurations:** Self-hosted deployments that expose the
18+
streamable-HTTP port on a reachable network interface, especially when
19+
co-located with internal services or a cloud metadata endpoint. The hosted
20+
MCP at `*.mcp.pipeboard.co` binds the Python process to localhost behind an
21+
authenticating proxy, so it was not reachable by unauthenticated callers; the
22+
fix is still applied there as defense-in-depth.
23+
24+
**What went wrong.** `upload_ad_image` (and the image-viewing tools) passed a
25+
caller-supplied URL straight to an HTTP client (`download_image` /
26+
`try_multiple_download_methods`) with `follow_redirects=True` and no scheme,
27+
host, or IP validation. A caller could supply `http://127.0.0.1/...`, an RFC
28+
1918 address, or `http://169.254.169.254/` (cloud instance metadata) and make
29+
the server issue outbound requests to those targets. On the streamable-HTTP
30+
transport the image fetch ran before Meta credential validation, so any
31+
non-empty bearer token reached the sink.
32+
33+
**Fix.**
34+
1. A new `validate_public_url()` guard restricts fetches to `http`/`https`
35+
URLs whose host resolves only to public addresses. Private, loopback,
36+
link-local (incl. `169.254.169.254`), reserved, multicast, and unspecified
37+
addresses are rejected; IPv4-mapped IPv6 addresses are unwrapped first.
38+
2. An httpx request event hook re-validates every redirect hop, so a public URL
39+
cannot redirect into a private/internal address.
40+
41+
**Action for operators.**
42+
- Upgrade to `1.0.115` or later.
43+
- If you exposed an earlier version on a reachable interface, review outbound
44+
request and access logs for unexpected internal fetches, and ensure the cloud
45+
metadata service is hardened (e.g. IMDSv2 / metadata not reachable from the
46+
app process).
47+
1248
### GHSA-9gw6-46qc-99vr — Unauthenticated HTTP MCP tool execution leaks operator Meta access token
1349

1450
- **Severity:** Critical (CVSS 3.1 9.1 — `AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:N`)

‎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.114"
9+
__version__ = "1.0.115"
1010

1111
__all__ = [
1212
'get_ad_accounts',

‎meta_ads_mcp/core/utils.py‎

Lines changed: 148 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,9 @@
1212
import logging
1313
import pathlib
1414
import platform
15+
import ipaddress
16+
import socket
17+
from urllib.parse import urlparse
1518

1619
# Check for Meta app credentials in environment
1720
META_APP_ID = os.environ.get("META_APP_ID", "")
@@ -144,37 +147,153 @@ def extract_creative_image_urls(creative: Dict[str, Any]) -> List[str]:
144147
return unique_urls
145148

146149

150+
# --- Server-side request forgery (SSRF) guard for outbound image fetches ---
151+
#
152+
# upload_ad_image and the image-viewing tools fetch a caller-supplied URL
153+
# server-side. Without validation an attacker could point the URL at internal
154+
# services (http://127.0.0.1/...), private networks (10.x/192.168.x/172.16.x),
155+
# or the cloud metadata endpoint (http://169.254.169.254/) and use the server
156+
# as a proxy. See GHSA-45gf-fjxp-cjpq.
157+
#
158+
# Known residual: a hostname that resolves to a public IP at validation time
159+
# but to a private IP at connection time (DNS rebinding) is not fully closed,
160+
# since httpx resolves independently when it connects. The practical vectors
161+
# (a directly-internal URL, and a public URL that redirects inward) are blocked.
162+
163+
class BlockedURLError(Exception):
164+
"""Raised when a URL targets a disallowed (non-public) address."""
165+
166+
167+
_ALLOWED_URL_SCHEMES = ("http", "https")
168+
169+
170+
def _ip_is_disallowed(ip) -> bool:
171+
"""Return True if `ip` is not a public, routable address.
172+
173+
Blocks private, loopback, link-local (incl. 169.254.169.254 cloud
174+
metadata), reserved, multicast, and unspecified addresses. IPv4-mapped
175+
IPv6 addresses (e.g. ::ffff:127.0.0.1) are unwrapped first so they can't
176+
be used to smuggle a private IPv4 target past the check.
177+
"""
178+
mapped = getattr(ip, "ipv4_mapped", None)
179+
if mapped is not None:
180+
ip = mapped
181+
return (
182+
ip.is_private
183+
or ip.is_loopback
184+
or ip.is_link_local
185+
or ip.is_reserved
186+
or ip.is_multicast
187+
or ip.is_unspecified
188+
)
189+
190+
191+
def validate_public_url(url: str) -> None:
192+
"""Validate that `url` is safe to fetch from the server (SSRF guard).
193+
194+
Raises BlockedURLError if the URL is not http(s), has no host, or resolves
195+
to any non-public address. A literal-IP host is checked directly; a
196+
hostname is resolved and every returned address must be public.
197+
"""
198+
if not url or not isinstance(url, str):
199+
raise BlockedURLError("No URL provided")
200+
201+
parsed = urlparse(url.strip())
202+
scheme = (parsed.scheme or "").lower()
203+
if scheme not in _ALLOWED_URL_SCHEMES:
204+
raise BlockedURLError(
205+
f"URL scheme '{parsed.scheme}' is not allowed; "
206+
"only http and https URLs can be fetched"
207+
)
208+
209+
host = parsed.hostname
210+
if not host:
211+
raise BlockedURLError("URL has no host")
212+
213+
try:
214+
candidate_ips = [ipaddress.ip_address(host)]
215+
except ValueError:
216+
# Not a literal IP — resolve the hostname and check every address.
217+
try:
218+
infos = socket.getaddrinfo(host, None)
219+
except socket.gaierror as e:
220+
raise BlockedURLError(f"Could not resolve host '{host}': {e}")
221+
candidate_ips = []
222+
for info in infos:
223+
ip_text = info[4][0].split("%")[0] # strip any IPv6 scope id
224+
try:
225+
candidate_ips.append(ipaddress.ip_address(ip_text))
226+
except ValueError:
227+
continue
228+
if not candidate_ips:
229+
raise BlockedURLError(f"Could not resolve host '{host}' to any IP address")
230+
231+
for ip in candidate_ips:
232+
if _ip_is_disallowed(ip):
233+
raise BlockedURLError(
234+
f"Refusing to fetch '{host}': it resolves to a non-public address "
235+
f"({ip}). Private, loopback, link-local, and cloud-metadata "
236+
"addresses are blocked to prevent server-side request forgery."
237+
)
238+
239+
240+
async def _ssrf_guard_request_hook(request: "httpx.Request") -> None:
241+
"""httpx request event hook that re-validates every outbound request.
242+
243+
Fires for the initial request and for each redirect hop, so a public URL
244+
cannot redirect into a private/internal address.
245+
"""
246+
validate_public_url(str(request.url))
247+
248+
147249
async def download_image(url: str) -> Optional[bytes]:
148250
"""
149251
Download an image from a URL.
150-
252+
151253
Args:
152254
url: Image URL
153-
255+
154256
Returns:
155257
Image data as bytes if successful, None otherwise
156258
"""
259+
# SSRF guard: refuse non-public targets before opening any connection.
260+
try:
261+
validate_public_url(url)
262+
except BlockedURLError as e:
263+
logger.warning("Refusing to download image from disallowed URL: %s", e)
264+
print(f"Refusing to download image from disallowed URL: {e}")
265+
return None
266+
157267
try:
158268
print(f"Attempting to download image from URL: {url}")
159-
269+
160270
# Use minimal headers like curl does
161271
headers = {
162272
"User-Agent": "curl/8.4.0",
163273
"Accept": "*/*"
164274
}
165-
166-
async with httpx.AsyncClient(follow_redirects=True, timeout=30.0) as client:
275+
276+
async with httpx.AsyncClient(
277+
follow_redirects=True,
278+
timeout=30.0,
279+
event_hooks={"request": [_ssrf_guard_request_hook]},
280+
) as client:
167281
# Simple GET request just like curl
168282
response = await client.get(url, headers=headers)
169-
283+
170284
# Check response
171285
if response.status_code == 200:
172286
print(f"Successfully downloaded image: {len(response.content)} bytes")
173287
return response.content
174288
else:
175289
print(f"Failed to download image: HTTP {response.status_code}")
176290
return None
177-
291+
292+
except BlockedURLError as e:
293+
# A redirect pointed at a disallowed (non-public) address.
294+
logger.warning("Blocked SSRF redirect during image download: %s", e)
295+
print(f"Blocked image download (redirect to disallowed address): {e}")
296+
return None
178297
except httpx.HTTPStatusError as e:
179298
print(f"HTTP Error when downloading image: {e}")
180299
return None
@@ -195,33 +314,48 @@ async def try_multiple_download_methods(url: str) -> Optional[bytes]:
195314
196315
Returns:
197316
Image data as bytes if successful, None otherwise
317+
318+
Raises:
319+
BlockedURLError: if `url` targets a non-public address (SSRF guard),
320+
raised up-front so callers can surface a clear rejection message.
198321
"""
322+
# SSRF guard: validate once up-front and propagate a clear error. Each
323+
# client below also re-validates every request (including redirect hops)
324+
# via _ssrf_guard_request_hook, so a public URL cannot redirect inward.
325+
validate_public_url(url)
326+
199327
# Method 1: Direct download with custom headers
200328
image_data = await download_image(url)
201329
if image_data:
202330
return image_data
203-
331+
204332
print("Direct download failed, trying alternative methods...")
205-
333+
206334
# Method 2: Try adding Facebook cookie simulation
207335
try:
208336
headers = {
209337
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36",
210338
"Accept": "image/webp,image/apng,image/*,*/*;q=0.8",
211339
"Cookie": "presence=EDvF3EtimeF1697900316EuserFA21B00112233445566AA0EstateFDutF0CEchF_7bCC" # Fake cookie
212340
}
213-
214-
async with httpx.AsyncClient(follow_redirects=True) as client:
341+
342+
async with httpx.AsyncClient(
343+
follow_redirects=True,
344+
event_hooks={"request": [_ssrf_guard_request_hook]},
345+
) as client:
215346
response = await client.get(url, headers=headers, timeout=30.0)
216347
response.raise_for_status()
217348
print(f"Method 2 succeeded with cookie simulation: {len(response.content)} bytes")
218349
return response.content
219350
except Exception as e:
220351
print(f"Method 2 failed: {str(e)}")
221-
352+
222353
# Method 3: Try with session that keeps redirects and cookies
223354
try:
224-
async with httpx.AsyncClient(follow_redirects=True) as client:
355+
async with httpx.AsyncClient(
356+
follow_redirects=True,
357+
event_hooks={"request": [_ssrf_guard_request_hook]},
358+
) as client:
225359
# First visit Facebook to get cookies
226360
await client.get("https://www.facebook.com/", timeout=30.0)
227361
# Then try the image URL
@@ -231,7 +365,7 @@ async def try_multiple_download_methods(url: str) -> Optional[bytes]:
231365
return response.content
232366
except Exception as e:
233367
print(f"Method 3 failed: {str(e)}")
234-
368+
235369
return None
236370

237371

‎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.114"
7+
version = "1.0.115"
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.114",
5+
"version": "1.0.115",
66
"remotes": [
77
{
88
"type": "streamable-http",

0 commit comments

Comments
 (0)