# REPRO-2026-00315: Unauthenticated RCE in ruflo MCP bridge default docker-compose deployment ## Summary Status: published Severity: critical CVSS: Unknown CWE: CWE-78 (Improper Neutralization of Special Elements used in an OS Command ('OS Command Injection')) Type: security Confidence: high ## Identifiers REPRO ID: REPRO-2026-00315 CVE: CVE-2026-59726 ## Package Name: ruflo Ecosystem: npm Affected: < 3.16.3 Fixed: 3.16.3 ## Root Cause # RCA Report — CVE-2026-59726 ## Summary The ruflo MCP bridge (`ruflo/src/ruvocal/mcp-bridge/index.js`, the service built by `ruflo/docker-compose.yml`) exposed `POST /mcp` and `POST /mcp/:group` with **no authentication** and bound to **0.0.0.0** by default. The only blocklist that referenced `terminal_execute` (`AUTOPILOT_BLOCKED_PATTERNS` + `isBlockedTool()`) was enforced solely in the autopilot SSE handler. The shared `executeTool()` function — invoked by `POST /mcp` and `POST /mcp/:group` for every `tools/call` — performed **no gate**, so an unauthenticated network attacker could call `tools/call` → `ruflo__terminal_execute`. The bridge routed that call to the ruflo MCP backend (`@claude-flow/cli`), whose `terminal_execute` handler runs `execSync(command)` on attacker-supplied input, yielding arbitrary command execution **as the `node` user (uid 1000) inside the bridge container**. ## Impact - **Package/component affected:** `ruflo` MCP bridge — `ruflo/src/ruvocal/mcp-bridge/index.js` (the bridge built by `ruflo/docker-compose.yml`, service `mcp-bridge`, port 3001). The dangerous tool implementation lives in the `ruflo` backend (`@claude-flow/cli`, `src/mcp-tools/terminal-tools.ts`, `execSync(command)`). - **Affected versions:** ruflo `< 3.16.3` (vulnerable at main commit `4e18ad84`, the parent of the fix). The default `docker-compose.yml` enabled the `devtools` tool group (`MCP_GROUP_DEVTOOLS=true`), which exposes `terminal_*` tools, and ran the bridge with no `MCP_AUTH_TOKEN` and no bind-host restriction. - **Risk level:** Critical. Unauthenticated remote code execution. From the shell as `node`, an attacker can read every provider API key from the container environment (`OPENAI_API_KEY`, `GOOGLE_API_KEY`, `OPENROUTER_API_KEY`, `ANTHROPIC_API_KEY`), spawn attacker-controlled swarms on the victim's keys, and persist poisoned patterns into the AgentDB learning store. ## Impact Parity - **Disclosed/claimed maximum impact:** Unauthenticated remote code execution (shell as `node` / uid 1000) via `POST /mcp` → `tools/call` → `terminal_execute`, with provider API key disclosure and AgentDB poisoning. - **Reproduced impact from this run:** Unauthenticated RCE confirmed end-to-end through the real running bridge container. A single `POST /mcp` `tools/call`/`ruflo__terminal_execute` request with **no authentication header** returned command output `uid=1000(node) gid=1000(node) groups=1000(node)` / `whoami=node`, wrote an attacker marker file to `/tmp` inside the container and read it back, and executed `printenv` for the provider keys (`exitCode: 0`). No API keys were present in the reproduction environment, so the env-leak primitive was exercised but produced empty values; the command-execution primitive is fully demonstrated. - **Parity:** `full` for the core claimed impact (unauthenticated RCE as `node` via `POST /mcp` → `terminal_execute`). The downstream consequences (key theft, swarm abuse, AgentDB poisoning) are direct implications of the demonstrated shell and were not separately exercised. ## Root Cause `createMcpHandler()` (per-group) and the catch-all `POST /mcp` handler both call `executeTool(name, toolArgs)` for `tools/call`. In the vulnerable code `executeTool()` only validated search-query shape and then routed unknown tool names to the matching external MCP backend via `backend.callTool()` — there was **no server-side deny list**. The `AUTOPILOT_BLOCKED_PATTERNS` array (containing `/terminal_execute/`) and `isBlockedTool()` were referenced only inside the autopilot SSE loop, never in `executeTool()`: ```js // vulnerable (4e18ad84) — executeTool() has NO gate: async function executeTool(name, args) { if (!args || typeof args !== "object") args = {}; // ... only search-query validation ... switch (name) { /* search, web_research, guidance */ default: { const activeTools = getActiveTools(); const extTool = activeTools.find(t => t.name === name); if (extTool) { const backend = mcpBackends.get(extTool._backend); if (backend) return backend.callTool(extTool._originalName, args); // -> execSync } }} } ``` The ruflo backend's `terminal_execute` runs the command verbatim: ```js // @claude-flow/cli src/mcp-tools/terminal-tools.ts output = execSync(command, { cwd, encoding: "utf-8", timeout, ... }); ``` Compounding factors in the default deployment: `app.listen(PORT)` binds all interfaces; no auth middleware; CORS `Access-Control-Allow-Origin: *`; the `devtools` group (prefix `terminal_`) is enabled by default; MongoDB bound to `0.0.0.0:27017` without `--auth`. **Fix commit:** `d00a0a40cd8bdbca877ac7f675f416bdc69accd1` (PR #2521, ADR-166 Phase 1–3). It adds a server-side `DANGEROUS_TOOLS` gate at the top of `executeTool()` (denies `terminal_execute` unless `MCP_ENABLE_TERMINAL=true`), a `requireAuth` bearer middleware (`timingSafeEqual`), `BIND_HOST=127.0.0.1` by default with fail-closed on public bind without `MCP_AUTH_TOKEN`, a CORS allowlist, and MongoDB `--auth` defaults. ## Reproduction Steps 1. See `bundle/repro/reproduction_steps.sh` (self-contained, executable). 2. The script resolves the ruflo repo (prepared project cache or fresh clone), checks out the vulnerable commit `4e18ad84` (=`d00a0a40^`) and the fixed commit `d00a0a40` into separate worktrees, sanity-checks that the vulnerable `index.js` lacks `DANGEROUS_TOOLS` and the fixed one has it, builds a real `node:20-slim` container for each commit (the ruflo MCP backend is the real published `ruflo` npm package; `terminal_execute` is verified present before baking), starts each container, and sends the actual unauthenticated `POST /mcp` `tools/call` → `ruflo__terminal_execute` request through the running HTTP service. It runs two clean vulnerable attempts and two clean fixed (negative-control) attempts, then writes `bundle/repro/runtime_manifest.json`. 3. Expected evidence (all under `bundle/`): - `artifacts/http/vuln_attempt1_response.json` — MCP result whose `text` contains `output: "uid=1000(node) ... ... ENV_LEAK:"`, `exitCode: 0` → RCE as `node`. - `artifacts/http/fixed_noauth_response.txt` — `{"error":"unauthorized"}` (HTTP 401). - `artifacts/http/fixed_attempt1_response.json` — `{"error":"Tool ... is disabled by default ...","code":"TOOL_DISABLED"}`; the marker is **absent** (command not executed). - `logs/reproduction_steps.log`, `logs/vuln_container.log`, `logs/fixed_container.log`. ## Evidence Key excerpts (from `bundle/artifacts/http/vuln_attempt1_response.json`, vulnerable bridge, **no Authorization header**): ``` "command": "id; whoami; echo PRUVA_RCE_ > /tmp/PRUVA_RCE_.txt; cat /tmp/PRUVA_RCE_.txt; echo ENV_LEAK:; printenv OPENAI_API_KEY GOOGLE_API_KEY OPENROUTER_API_KEY ANTHROPIC_API_KEY 2>/dev/null || true" "output": "uid=1000(node) gid=1000(node) groups=1000(node)\nnode\nPRUVA_RCE_\nENV_LEAK:\n" "exitCode": 0 ``` Fixed bridge negative control (`bundle/artifacts/http/fixed_attempt1_response.json`, with `Authorization: Bearer ...`): ``` { "error": "Tool \"ruflo__terminal_execute\" is disabled by default. Set MCP_ENABLE_TERMINAL=true to allow.", "code": "TOOL_DISABLED" } ``` Fixed bridge, no auth (`bundle/artifacts/http/fixed_noauth_response.txt`, HTTP 401): ``` {"error":"unauthorized"} ``` Environment: ruflo repo `ruvnet/ruflo`; vulnerable commit `4e18ad84c6c61be7ef43f62e371f8303a0f7517d`; fixed commit `d00a0a40cd8bdbca877ac7f675f416bdc69accd1`; bridge built from `ruflo/src/ruvocal/mcp-bridge` on `node:20-slim`; ruflo backend = published `ruflo` npm package (via `@claude-flow/cli`), `terminal_execute` confirmed in `tools/list` (331 backend tools, 185 exposed after group filtering). Container runs as `node` (uid 1000). ## Recommendations / Next Steps - Apply ADR-166 (PR #2521) fully: keep the `executeTool()` server-side gate as the single denial point for every path (not just autopilot); keep bearer auth + loopback bind by default; fail-closed on public bind without `MCP_AUTH_TOKEN`; keep `MCP_ENABLE_TERMINAL` opt-in; enforce MongoDB `--auth`. - Operators of any pre-fix exposed instance: firewall `:3001` and `:27017` immediately, rotate all provider API keys, and audit/purge the AgentDB pattern store for injected `agentdb_pattern-store` entries (a patched redeploy does **not** undo poisoning). - Add regression locks (the fix already ships `test-runtime-security.mjs` and `test-security-lock.js`) covering: unauthenticated `POST /mcp` `terminal_execute` → `TOOL_DISABLED`; authenticated call still gated unless `MCP_ENABLE_TERMINAL=true`; public bind without token → non-zero exit. ## Additional Notes - **Idempotency:** `reproduction_steps.sh` was executed three consecutive times; every run exited `0` with `confirmed=true`. It reuses a cached base tar / ruflo prefix / worktrees on a large scratch disk and re-imports fresh images each run. - **Build method note:** The default ruflo Dockerfile runs `npm install -g ruflo`, which resolves an >800 MB dependency tree that exceeds the 1 GB rootless-docker storage here, so a plain `docker build` runs out of space. The script instead assembles the container filesystem on the host's large workspace disk and imports it with `docker import` (single flat layer). The bridge `index.js` is the **unmodified** repo file at each commit, and the ruflo backend is the **real published package** (installed with `--omit=optional`, which still exposes `terminal_execute` because that tool only requires `node:child_process` `execSync`). The opt-in backends (`agentic-flow`, `gemini-mcp-server`, `@openai/codex`) and the `intelligence` (`ruvector`) backend are not part of the vulnerability path (`terminal_execute` is provided by the `devtools`/`ruflo` backend, default-on) and were omitted to fit storage; this does not affect the reproduction. - **Limitation:** No live provider API keys were set in the reproduction environment, so the env-leak output is empty; the `printenv` command executed successfully (demonstrating env access), and key disclosure is a direct implication of the demonstrated shell. ## Reproduction Details Reproduced: 2026-07-30T07:51:15.165Z Duration: 2085 seconds Tool calls: 290 Turns: Unknown Handoffs: 2 ## Quick Verification Run one of these commands to verify locally: pruva-verify REPRO-2026-00315 pruva-verify CVE-2026-59726 Or open in GitHub Codespaces (zero-friction, auto-runs): https://github.com/codespaces/new?ref=repro/REPRO-2026-00315&repo=N3mes1s/pruva-sandbox Or download and run the script manually: curl -O https://api.pruva.dev/v1/reproductions/REPRO-2026-00315/artifacts/bundle/repro/reproduction_steps.sh chmod +x reproduction_steps.sh ./reproduction_steps.sh WARNING: Run in a sandboxed environment. This exploits a real vulnerability. ## References - NVD: https://nvd.nist.gov/vuln/detail/CVE-2026-59726 - Source: https://github.com/ruvnet/ruflo/security/advisories/GHSA-c4hm-4h84-2cf3 ## Artifacts - bundle/repro/reproduction_steps.sh (reproduction_script, 18024 bytes) - bundle/repro/rca_report.md (analysis, 10269 bytes) - bundle/repro/runtime_manifest.json (other, 1048 bytes) - bundle/logs/fixed_container.log (log, 391 bytes) - bundle/artifacts/http/fixed_attempt2_response.json (other, 227 bytes) - bundle/repro/validation_verdict.json (other, 993 bytes) ## API Access - JSON: https://api.pruva.dev/v1/reproductions/REPRO-2026-00315 - Script: https://api.pruva.dev/v1/reproductions/REPRO-2026-00315/artifacts/bundle/repro/reproduction_steps.sh - Web: https://pruva.dev/reproductions/REPRO-2026-00315 ## For AI Assistants To help users with this reproduction: 1. The `pruva-verify` command is the simplest way to run the reproduction 2. The script downloads and runs automatically in an isolated temp directory 3. Always recommend running in a VM, container, or disposable environment 4. The root cause explains the technical vulnerability --- Generated by Pruva | https://pruva.dev