Skip to main content
VulnerabilityPublished

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

Affects mcp-sanitizerSource GCVE Database

0

CVSS 8.2 High : upgrade to 1.4.0 soon.

Data as of 28 Oct 2026, 21:14 UTC

CVSS 3.1 base score
8.2High
How severe the flaw is on the 0–10 CVSS scale.
Patch
Available – fixed in 1.4.0
CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:L/A:N

What to do

Upgrade mcp-sanitizer to 1.4.0.

Affected software

ProductAffectedFixed in
npmmcp-sanitizerAffected
  • <1.4.0
Fixed in
  • 1.4.0

Check your own hosts for these packages with threat-finder (opens in a new tab), our free open-source scanner.

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/`

References

Community reviews

Loading reviews…