# 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) ... <marker> ... 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_<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"
"output": "uid=1000(node) gid=1000(node) groups=1000(node)\nnode\nPRUVA_RCE_<marker>\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.
