{"repro_id":"REPRO-2026-00315","version":6,"title":"Unauthenticated RCE in ruflo MCP bridge default docker-compose deployment","repro_type":"security","status":"published","severity":"critical","description":"The ruflo MCP bridge shipping in docker-compose defaults exposes POST /mcp with no authentication and binds the bridge and MongoDB to all interfaces. An unauthenticated network attacker can invoke tools/call -> terminal_execute inside the bridge container, obtain a shell as node, read provider API keys from the container environment, spawn attacker-controlled swarms on victim keys, and persist poisoned patterns into AgentDB that may steer future AI outputs. The vulnerable blocklist for terminal_execute was enforced only in the autopilot flow; POST /mcp and POST /mcp/:group bypass it. Reproduction should focus on the default docker-compose deployment and the MCP bridge request flow.","root_cause":"# RCA Report — CVE-2026-59726\n\n## Summary\n\nThe ruflo MCP bridge (`ruflo/src/ruvocal/mcp-bridge/index.js`, the service built by\n`ruflo/docker-compose.yml`) exposed `POST /mcp` and `POST /mcp/:group` with **no\nauthentication** and bound to **0.0.0.0** by default. The only blocklist that referenced\n`terminal_execute` (`AUTOPILOT_BLOCKED_PATTERNS` + `isBlockedTool()`) was enforced solely in\nthe autopilot SSE handler. The shared `executeTool()` function — invoked by `POST /mcp` and\n`POST /mcp/:group` for every `tools/call` — performed **no gate**, so an unauthenticated\nnetwork attacker could call `tools/call` → `ruflo__terminal_execute`. The bridge routed that\ncall to the ruflo MCP backend (`@claude-flow/cli`), whose `terminal_execute` handler runs\n`execSync(command)` on attacker-supplied input, yielding arbitrary command execution **as the\n`node` user (uid 1000) inside the bridge container**.\n\n## Impact\n\n- **Package/component affected:** `ruflo` MCP bridge — `ruflo/src/ruvocal/mcp-bridge/index.js`\n  (the bridge built by `ruflo/docker-compose.yml`, service `mcp-bridge`, port 3001). The\n  dangerous tool implementation lives in the `ruflo` backend (`@claude-flow/cli`,\n  `src/mcp-tools/terminal-tools.ts`, `execSync(command)`).\n- **Affected versions:** ruflo `< 3.16.3` (vulnerable at main commit `4e18ad84`, the parent\n  of the fix). The default `docker-compose.yml` enabled the `devtools` tool group\n  (`MCP_GROUP_DEVTOOLS=true`), which exposes `terminal_*` tools, and ran the bridge with no\n  `MCP_AUTH_TOKEN` and no bind-host restriction.\n- **Risk level:** Critical. Unauthenticated remote code execution. From the shell as `node`,\n  an attacker can read every provider API key from the container environment\n  (`OPENAI_API_KEY`, `GOOGLE_API_KEY`, `OPENROUTER_API_KEY`, `ANTHROPIC_API_KEY`), spawn\n  attacker-controlled swarms on the victim's keys, and persist poisoned patterns into the\n  AgentDB learning store.\n\n## Impact Parity\n\n- **Disclosed/claimed maximum impact:** Unauthenticated remote code execution (shell as\n  `node` / uid 1000) via `POST /mcp` → `tools/call` → `terminal_execute`, with provider API\n  key disclosure and AgentDB poisoning.\n- **Reproduced impact from this run:** Unauthenticated RCE confirmed end-to-end through the\n  real running bridge container. A single `POST /mcp`\n  `tools/call`/`ruflo__terminal_execute` request with **no authentication header** returned\n  command output `uid=1000(node) gid=1000(node) groups=1000(node)` / `whoami=node`, wrote an\n  attacker marker file to `/tmp` inside the container and read it back, and executed\n  `printenv` for the provider keys (`exitCode: 0`). No API keys were present in the\n  reproduction environment, so the env-leak primitive was exercised but produced empty\n  values; the command-execution primitive is fully demonstrated.\n- **Parity:** `full` for the core claimed impact (unauthenticated RCE as `node` via\n  `POST /mcp` → `terminal_execute`). The downstream consequences (key theft, swarm abuse,\n  AgentDB poisoning) are direct implications of the demonstrated shell and were not\n  separately exercised.\n\n## Root Cause\n\n`createMcpHandler()` (per-group) and the catch-all `POST /mcp` handler both call\n`executeTool(name, toolArgs)` for `tools/call`. In the vulnerable code `executeTool()` only\nvalidated search-query shape and then routed unknown tool names to the matching external MCP\nbackend via `backend.callTool()` — there was **no server-side deny list**. The\n`AUTOPILOT_BLOCKED_PATTERNS` array (containing `/terminal_execute/`) and `isBlockedTool()`\nwere referenced only inside the autopilot SSE loop, never in `executeTool()`:\n\n```js\n// vulnerable (4e18ad84) — executeTool() has NO gate:\nasync function executeTool(name, args) {\n  if (!args || typeof args !== \"object\") args = {};\n  // ... only search-query validation ...\n  switch (name) { /* search, web_research, guidance */ default: {\n      const activeTools = getActiveTools();\n      const extTool = activeTools.find(t => t.name === name);\n      if (extTool) {\n        const backend = mcpBackends.get(extTool._backend);\n        if (backend) return backend.callTool(extTool._originalName, args); // -> execSync\n      }\n  }}\n}\n```\n\nThe ruflo backend's `terminal_execute` runs the command verbatim:\n\n```js\n// @claude-flow/cli src/mcp-tools/terminal-tools.ts\noutput = execSync(command, { cwd, encoding: \"utf-8\", timeout, ... });\n```\n\nCompounding factors in the default deployment: `app.listen(PORT)` binds all interfaces; no\nauth middleware; CORS `Access-Control-Allow-Origin: *`; the `devtools` group (prefix\n`terminal_`) is enabled by default; MongoDB bound to `0.0.0.0:27017` without `--auth`.\n\n**Fix commit:** `d00a0a40cd8bdbca877ac7f675f416bdc69accd1` (PR #2521, ADR-166 Phase 1–3). It\nadds a server-side `DANGEROUS_TOOLS` gate at the top of `executeTool()` (denies\n`terminal_execute` unless `MCP_ENABLE_TERMINAL=true`), a `requireAuth` bearer middleware\n(`timingSafeEqual`), `BIND_HOST=127.0.0.1` by default with fail-closed on public bind without\n`MCP_AUTH_TOKEN`, a CORS allowlist, and MongoDB `--auth` defaults.\n\n## Reproduction Steps\n\n1. See `bundle/repro/reproduction_steps.sh` (self-contained, executable).\n2. The script resolves the ruflo repo (prepared project cache or fresh clone), checks out the\n   vulnerable commit `4e18ad84` (=`d00a0a40^`) and the fixed commit `d00a0a40` into separate\n   worktrees, sanity-checks that the vulnerable `index.js` lacks `DANGEROUS_TOOLS` and the\n   fixed one has it, builds a real `node:20-slim` container for each commit (the ruflo MCP\n   backend is the real published `ruflo` npm package; `terminal_execute` is verified present\n   before baking), starts each container, and sends the actual unauthenticated\n   `POST /mcp` `tools/call` → `ruflo__terminal_execute` request through the running HTTP\n   service. It runs two clean vulnerable attempts and two clean fixed (negative-control)\n   attempts, then writes `bundle/repro/runtime_manifest.json`.\n3. Expected evidence (all under `bundle/`):\n   - `artifacts/http/vuln_attempt1_response.json` — MCP result whose `text` contains\n     `output: \"uid=1000(node) ... <marker> ... ENV_LEAK:\"`, `exitCode: 0` → RCE as `node`.\n   - `artifacts/http/fixed_noauth_response.txt` — `{\"error\":\"unauthorized\"}` (HTTP 401).\n   - `artifacts/http/fixed_attempt1_response.json` — `{\"error\":\"Tool ... is disabled by\n     default ...\",\"code\":\"TOOL_DISABLED\"}`; the marker is **absent** (command not executed).\n   - `logs/reproduction_steps.log`, `logs/vuln_container.log`, `logs/fixed_container.log`.\n\n## Evidence\n\nKey excerpts (from `bundle/artifacts/http/vuln_attempt1_response.json`, vulnerable bridge,\n**no Authorization header**):\n\n```\n\"command\": \"id; whoami; echo PRUVA_RCE_<marker> > /tmp/PRUVA_RCE_<marker>.txt; cat /tmp/PRUVA_RCE_<marker>.txt; echo ENV_LEAK:; printenv OPENAI_API_KEY GOOGLE_API_KEY OPENROUTER_API_KEY ANTHROPIC_API_KEY 2>/dev/null || true\"\n\"output\": \"uid=1000(node) gid=1000(node) groups=1000(node)\\nnode\\nPRUVA_RCE_<marker>\\nENV_LEAK:\\n\"\n\"exitCode\": 0\n```\n\nFixed bridge negative control (`bundle/artifacts/http/fixed_attempt1_response.json`, with\n`Authorization: Bearer ...`):\n\n```\n{ \"error\": \"Tool \\\"ruflo__terminal_execute\\\" is disabled by default. Set MCP_ENABLE_TERMINAL=true to allow.\", \"code\": \"TOOL_DISABLED\" }\n```\n\nFixed bridge, no auth (`bundle/artifacts/http/fixed_noauth_response.txt`, HTTP 401):\n\n```\n{\"error\":\"unauthorized\"}\n```\n\nEnvironment: ruflo repo `ruvnet/ruflo`; vulnerable commit `4e18ad84c6c61be7ef43f62e371f8303a0f7517d`;\nfixed commit `d00a0a40cd8bdbca877ac7f675f416bdc69accd1`; bridge built from\n`ruflo/src/ruvocal/mcp-bridge` on `node:20-slim`; ruflo backend = published `ruflo` npm package\n(via `@claude-flow/cli`), `terminal_execute` confirmed in `tools/list` (331 backend tools,\n185 exposed after group filtering). Container runs as `node` (uid 1000).\n\n## Recommendations / Next Steps\n\n- Apply ADR-166 (PR #2521) fully: keep the `executeTool()` server-side gate as the single\n  denial point for every path (not just autopilot); keep bearer auth + loopback bind by\n  default; fail-closed on public bind without `MCP_AUTH_TOKEN`; keep `MCP_ENABLE_TERMINAL`\n  opt-in; enforce MongoDB `--auth`.\n- Operators of any pre-fix exposed instance: firewall `:3001` and `:27017` immediately,\n  rotate all provider API keys, and audit/purge the AgentDB pattern store for injected\n  `agentdb_pattern-store` entries (a patched redeploy does **not** undo poisoning).\n- Add regression locks (the fix already ships `test-runtime-security.mjs` and\n  `test-security-lock.js`) covering: unauthenticated `POST /mcp` `terminal_execute` →\n  `TOOL_DISABLED`; authenticated call still gated unless `MCP_ENABLE_TERMINAL=true`; public\n  bind without token → non-zero exit.\n\n## Additional Notes\n\n- **Idempotency:** `reproduction_steps.sh` was executed three consecutive times; every run\n  exited `0` with `confirmed=true`. It reuses a cached base tar / ruflo prefix / worktrees on\n  a large scratch disk and re-imports fresh images each run.\n- **Build method note:** The default ruflo Dockerfile runs `npm install -g ruflo`, which\n  resolves an >800 MB dependency tree that exceeds the 1 GB rootless-docker storage here, so a\n  plain `docker build` runs out of space. The script instead assembles the container\n  filesystem on the host's large workspace disk and imports it with `docker import` (single\n  flat layer). The bridge `index.js` is the **unmodified** repo file at each commit, and the\n  ruflo backend is the **real published package** (installed with `--omit=optional`, which\n  still exposes `terminal_execute` because that tool only requires `node:child_process`\n  `execSync`). The opt-in backends (`agentic-flow`, `gemini-mcp-server`, `@openai/codex`) and\n  the `intelligence` (`ruvector`) backend are not part of the vulnerability path\n  (`terminal_execute` is provided by the `devtools`/`ruflo` backend, default-on) and were\n  omitted to fit storage; this does not affect the reproduction.\n- **Limitation:** No live provider API keys were set in the reproduction environment, so the\n  env-leak output is empty; the `printenv` command executed successfully (demonstrating env\n  access), and key disclosure is a direct implication of the demonstrated shell.\n","cve_id":"CVE-2026-59726","cwe_id":"CWE-78","source_url":"https://github.com/ruvnet/ruflo/security/advisories/GHSA-c4hm-4h84-2cf3","package":{"name":"ruflo","ecosystem":"npm","affected_versions":"< 3.16.3","fixed_version":"3.16.3"},"reproduced_at":"2026-07-30T07:51:15.165537+00:00","duration_secs":2085.0,"tool_calls":290,"handoffs":2,"total_cost_usd":5.045816,"agent_costs":{"claim_matcher":0.020668,"judge":0.099555,"learning_policy":0.011837,"repro":4.03221,"support":0.075799,"vuln_variant":0.805747},"cost_breakdown":{"claim_matcher":{"gpt-5.4-mini-2026-03-17":0.020668},"judge":{"gpt-5.4-mini":0.078987,"gpt-5.4-mini-2026-03-17":0.020568},"learning_policy":{"gpt-5.4-mini-2026-03-17":0.011837},"repro":{"accounts/fireworks/routers/glm-5p2-fast":4.03221},"support":{"accounts/fireworks/routers/glm-5p2-fast":0.075799},"vuln_variant":{"accounts/fireworks/routers/glm-5p2-fast":0.805747}},"quality":{"confidence":"high","idempotent_verified":false,"community_verifications":0},"evidence":{"workflow":{"profile":"known_vulnerability","schema_version":2,"stages":["support","claim_contract","repro","judge","vuln_variant"]}},"environment":{"sandbox_image":"ghcr.io/n3mes1s/pruva-sandbox@sha256:8096b2518d6022e13d68f885c3b8ded6b4fe607098b1a1ccbfb99abc004d1dc1"},"published_at":"2026-07-30T07:51:15.738493+00:00","retracted":false,"artifacts":[{"path":"bundle/repro/reproduction_steps.sh","filename":"reproduction_steps.sh","size":18024,"category":"reproduction_script"},{"path":"bundle/repro/rca_report.md","filename":"rca_report.md","size":10269,"category":"analysis"},{"path":"bundle/repro/runtime_manifest.json","filename":"runtime_manifest.json","size":1048,"category":"other"},{"path":"bundle/logs/fixed_container.log","filename":"fixed_container.log","size":391,"category":"log"},{"path":"bundle/artifacts/http/fixed_attempt2_response.json","filename":"fixed_attempt2_response.json","size":227,"category":"other"},{"path":"bundle/repro/validation_verdict.json","filename":"validation_verdict.json","size":993,"category":"other"}]}