Root cause
Authentication & access control
feat(ruflo): add RuFlo Chat UI with ruvocal fork, MCP bridge, and Docker setup
How AI contributed: New attack surfacePublished Jul 1, 2026 in
ruvnet/ruflo
Ruflo is an agent meta-harness for Claude Code and Codex. Prior to 3.16.3, ruflo's default docker-compose deployment exposed the MCP bridge POST /mcp and POST /mcp/:group endpoints without authentication, allowing an unauthenticated network attacker to invoke tools/call to terminal_execute, obtain a shell in the bridge container, read provider API keys, and poison AgentDB learning-store patterns. This issue is fixed in version 3.16.3.
Introduced in a single commit that created the entire vulnerable surface: bridge server with app.listen(PORT) (no bind host = all interfaces), zero auth middleware, ungated terminal_execute tool, and docker-compose port mappings 3001:3001 / 27017:27017 to 0.0.0.0. Introducer parent has no bridge, no compose file, and no 0.0.0.0 anywhere under ruflo/.
Root cause
feat(ruflo): add RuFlo Chat UI with ruvocal fork, MCP bridge, and Docker setup
Fix
security(mcp-bridge): ADR-166 unauthenticated RCE — Phase 1-3 remediation + CI lock (#2521)
Case facts
@@ -0,0 +1,159 @@+#!/usr/bin/env node+/**+ * RVF WASM Kernel — MCP STDIO Transport+ *+ * Private in-process tunnel for MCP tool calls.+ * Runs inside the chat-ui container as a stdio MCP server,+ * forwarding tool requests to the MCP bridge over the internal+ * Docker network (HTTP). Bypasses HTTPS requirement since+ * stdio transport is trusted (no network exposure).+ *+ * RVF Segments Used:+ * WASM_SEG (0x10) — Lightweight query microkernel (~5KB control plane)+ * CRYPTO_SEG (0x0C) — Request signing for bridge authentication+ * META_IDX_SEG (0x0D) — Tool registry cache+ *+ * Architecture:+ * ┌──────────────┐ stdio ┌──────────────┐ HTTP ┌──────────────┐+ * │ HF Chat UI │◄───────►│ RVF Kernel │────────►│ MCP Bridge │+ * │ (SvelteKit) │ trusted │ (this file) │ private │ (Express) │+ * └──────────────┘ └──────────────┘ Docker └──────────────┘+ */++import { createInterface } from "readline";+import { createHmac, randomUUID } from "crypto";++// ---- RVF Kernel Configuration ----+const BRIDGE_URL = process.env.MCP_BRIDGE_URL || "http://mcp-bridge:3001";+const KERNEL_SECRET = process.env.RVF_KERNEL_SECRET || randomUUID();+const KERNEL_ID = `rvf-kernel-${process.pid}`;++// ---- META_IDX: Tool Registry Cache ----+let toolCache = null;+let toolCacheTime = 0;+const CACHE_TTL_MS = 60_000; // 1 minute++// ---- CRYPTO_SEG: Request Signing ----+function signRequest(payload) {+ const timestamp = Date.now();+ const nonce = randomUUID();+ const data = `${timestamp}:${nonce}:${JSON.stringify(payload)}`;+ const signature = createHmac("sha256", KERNEL_SECRET).update(data).digest("hex");+ return { timestamp, nonce, signature, kernelId: KERNEL_ID };+}++// ---- WASM_SEG: Core Kernel ----+async function forwardTobridge(method, params) {+ const body = {+ jsonrpc: "2.0",+ id: randomUUID(),+ method,+ ...(params ? { params } : {}),+ };++ const headers = {+ "Content-Type": "application/json",+ "X-RVF-Kernel": KERNEL_ID,+ };++ // Sign request if secret is configured+ if (process.env.RVF_KERNEL_SECRET) {+ const sig = signRequest(body);+ headers["X-RVF-Signature"] = sig.signature;+ headers["X-RVF-Timestamp"] = String(sig.timestamp);+ headers["X-RVF-Nonce"] = sig.nonce;+ }++ const resp = await fetch(`${BRIDGE_URL}/mcp`, {+ method: "POST",+ headers,+ body: JSON.stringify(body),+ signal: AbortSignal.timeout(30_000),+ });++ return resp.json();+}++async function handleRequest(request) {+ const { id, method, params } = request;++ switch (method) {+ case "initialize":+ return {+ jsonrpc: "2.0",+ id,+ result: {+ protocolVersion: "2024-11-05",+ capabilities: { tools: { listChanged: false } },+ serverInfo: {+ name: process.env.BRAND_NAME || "MCP Tools",@@ -0,0 +1,1864 @@+import express from "express";+import { spawn } from "child_process";+import { randomUUID } from "crypto";++// =============================================================================+// CONFIGURATION+// =============================================================================++const CLOUD_FUNCTIONS = {+ search: process.env.SEARCH_API_URL || null,+ research: process.env.RESEARCH_API_URL || null,+};++const PORT = parseInt(process.env.PORT || "3001", 10);++// =============================================================================+// TOOL GROUPS — Enable/disable categories of tools independently+// =============================================================================+// Groups map tool name prefixes from backends to logical categories.+// Each group can be toggled via env var. The AI sees only enabled tools.++const TOOL_GROUPS = {+ // --- Core (always on, built-in) ---+ core: {+ enabled: true, // cannot be disabled+ description: "Search, research, and guidance tools",+ source: "builtin",+ },++ // --- Intelligence (ruvector) ---+ intelligence: {+ enabled: process.env.MCP_GROUP_INTELLIGENCE !== "false",+ description: "Self-learning intelligence — routing, memory, pattern training (ruvector)",+ source: "ruvector",+ prefixes: ["hooks_"],+ },++ // --- Agents & Orchestration (ruflo) ---+ agents: {+ enabled: process.env.MCP_GROUP_AGENTS !== "false",+ description: "Agent lifecycle, swarm coordination, task management, workflows (ruflo)",+ source: "ruflo",+ prefixes: ["agent_", "swarm_", "task_", "session_", "hive-mind_", "workflow_", "coordination_"],+ },++ // --- Memory & Knowledge (ruflo) ---+ memory: {+ enabled: process.env.MCP_GROUP_MEMORY !== "false",+ description: "Vector memory, AgentDB, embeddings, semantic search (ruflo)",+ source: "ruflo",+ prefixes: ["memory_", "agentdb_", "embeddings_"],+ },++ // --- Dev Tools (ruflo) ---+ devtools: {+ enabled: process.env.MCP_GROUP_DEVTOOLS !== "false",+ description: "Hooks, code analysis, performance profiling, GitHub integration (ruflo)",+ source: "ruflo",+ prefixes: ["hooks_", "analyze_", "performance_", "github_", "terminal_", "config_", "system_", "progress_"],+ },++ // --- Security & Safety (ruflo) ---+ security: {+ enabled: process.env.MCP_GROUP_SECURITY === "true",+ description: "AI defence, PII detection, claims management, pattern transfer (ruflo)",+ source: "ruflo",+ prefixes: ["aidefence_", "claims_", "transfer_"],+ },++ // --- Browser Automation (ruflo) ---+ browser: {+ enabled: process.env.MCP_GROUP_BROWSER === "true",+ description: "Headless browser control — navigate, click, fill, screenshot (ruflo)",+ source: "ruflo",+ prefixes: ["browser_"],+ },++ // --- Neural & DAA (ruflo) ---+ neural: {+ enabled: process.env.MCP_GROUP_NEURAL === "true",+ description: "Neural network training, DAA autonomous agents, cognitive patterns (ruflo)",+ source: "ruflo",+ prefixes: ["neural_", "daa_"],+ },++ // --- Agentic Flow (agentic-flow@alpha) ---+ "agentic-flow": {+ enabled: process.env.MCP_GROUP_AGENTIC_FLOW === "true",+ description: "Execute 66+ specialized agents, batch code editing, AgentDB patterns (agentic-flow)",@@ -0,0 +1,470 @@+#!/usr/bin/env node+/**+ * MCP Bridge v2.0.0 — Complete Test Harness+ *+ * Tests:+ * 1. Health endpoint+ * 2. Groups endpoint+ * 3. MCP-servers endpoint (per-group config)+ * 4. Per-group MCP endpoints (initialize, tools/list, tools/call)+ * 5. Catch-all /mcp endpoint (backwards compat)+ * 6. Guidance tool (all topics)+ * 7. Chat completions proxy (model resolution)+ * 8. SSE endpoints (GET /mcp, GET /mcp/{group})+ * 9. Error handling (unknown tool, unknown method)+ * 10. Tool execution for each group+ *+ * Usage:+ * node test-harness.js [base-url]+ * Default: http://localhost:3001+ */++const BASE = process.argv[2] || "http://localhost:3001";++let passed = 0;+let failed = 0;+let skipped = 0;+const results = [];++function log(icon, msg) { console.log(` ${icon} ${msg}`); }++async function test(name, fn) {+ try {+ await fn();+ passed++;+ results.push({ name, status: "PASS" });+ log("✅", name);+ } catch (err) {+ failed++;+ results.push({ name, status: "FAIL", error: err.message });+ log("❌", `${name}: ${err.message}`);+ }+}++function skip(name, reason) {+ skipped++;+ results.push({ name, status: "SKIP", reason });+ log("⏭️ ", `${name} — ${reason}`);+}++function assert(cond, msg) { if (!cond) throw new Error(msg); }++async function fetchJSON(path, options = {}) {+ const res = await fetch(`${BASE}${path}`, options);+ return { status: res.status, data: await res.json(), headers: res.headers };+}++async function mcpCall(path, method, params = {}) {+ const { data } = await fetchJSON(path, {+ method: "POST",+ headers: { "Content-Type": "application/json" },+ body: JSON.stringify({ jsonrpc: "2.0", id: `test-${Date.now()}`, method, params }),+ });+ return data;+}++// =============================================================================+// TEST SUITES+// =============================================================================++async function testHealth() {+ console.log("\n── Health & Discovery ──");++ await test("GET /health returns 200", async () => {+ const { status, data } = await fetchJSON("/health");+ assert(status === 200, `status ${status}`);+ assert(data.status === "ok", `status: ${data.status}`);+ assert(data.version === "2.0.0", `version: ${data.version}`);+ });++ await test("GET /health includes groups", async () => {+ const { data } = await fetchJSON("/health");+ assert(data.groups, "missing groups");+ assert(data.groups.core?.enabled === true, "core not enabled");+ assert(data.groups.browser?.enabled === false, "browser should be disabled");+ });++ await test("GET /health includes tool counts", async () => {+ const { data } = await fetchJSON("/health");+ assert(data.tools.builtin === 3, `builtin: ${data.tools.builtin}`);@@ -129,7 +129,7 @@ function makeContext(): PluginContext { return { config: { nodeName: process.env.FEDERATION_NODE_NAME ?? hostname(),- bindHost: process.env.FEDERATION_BIND_HOST ?? '0.0.0.0',+ bindHost: process.env.FEDERATION_BIND_HOST ?? '127.0.0.1', bindPort: Number(process.env.FEDERATION_BIND_PORT ?? 8443), defaultTrustLevel: process.env.FEDERATION_TRUST_LEVEL ?? 'untrusted', },@@ -1,6 +1,6 @@ import express from "express"; import { spawn } from "child_process";-import { randomUUID } from "crypto";+import { randomUUID, timingSafeEqual } from "crypto"; // ============================================================================= // CONFIGURATION@@ -12,6 +12,7 @@ const CLOUD_FUNCTIONS = { }; const PORT = parseInt(process.env.PORT || "3001", 10);+const BIND_HOST = process.env.MCP_BIND_HOST || "127.0.0.1"; // ============================================================================= // TOOL GROUPS — Enable/disable categories of tools independently@@ -942,7 +943,30 @@ async function executeGoapSearchGemini(query, args) { // TOOL EXECUTOR // ============================================================================= +// ADR-166 §6 Phase 1d + 2a — server-side tool gate.+// Enforced HERE (not just in the autopilot handler) so /mcp, /mcp/:group,+// autopilot, and any future path share ONE denial gate. Was the missing+// link that made the disclosed unauthenticated-RCE chain reach shell.+const DANGEROUS_TOOLS = Object.freeze(new Set([+ "terminal_execute",+ "ruflo__terminal_execute",+ "devtools__terminal_execute",+]));+function isTerminalTool(name) {+ return DANGEROUS_TOOLS.has(name) || /terminal_execute/i.test(name);+}+const MCP_ENABLE_TERMINAL = process.env.MCP_ENABLE_TERMINAL === "true";+ async function executeTool(name, args) {+ // Deny dangerous tools unless the operator explicitly opted in.+ // Enforced on every path (not just autopilot) — root cause of ADR-166 V2/V3.+ if (isTerminalTool(name) && !MCP_ENABLE_TERMINAL) {+ return {+ error:+ `Tool "${name}" is disabled by default. Set MCP_ENABLE_TERMINAL=true to allow.`,+ code: "TOOL_DISABLED",+ };+ } switch (name) { case "search": { if (CLOUD_FUNCTIONS.search) {@@ -1033,15 +1057,44 @@ const GROUP_DISPLAY_NAMES = { const app = express(); app.use(express.json({ limit: "10mb" })); -// ---------- CORS middleware ----------+// ---------- CORS middleware (ADR-166 §6 Phase 3b) ----------+// MCP_CORS_ORIGIN: comma-separated allowlist (e.g. "https://a.example,https://b.example").+// unset → "*" for back-compat (loopback default is same-origin anyway)+// "*" → wildcard (explicit opt-in — same as legacy behavior)+// other → echo the request origin ONLY if it appears in the allowlist+const CORS_ALLOWLIST = (process.env.MCP_CORS_ORIGIN || "*")+ .split(",").map(s => s.trim()).filter(Boolean);+const CORS_WILDCARD = CORS_ALLOWLIST.length === 1 && CORS_ALLOWLIST[0] === "*"; app.use((req, res, next) => {- res.setHeader("Access-Control-Allow-Origin", "*");+ const origin = req.get("origin") || "";+ if (CORS_WILDCARD) {+ res.setHeader("Access-Control-Allow-Origin", "*");+ } else if (origin && CORS_ALLOWLIST.includes(origin)) {+ res.setHeader("Access-Control-Allow-Origin", origin);+ res.setHeader("Vary", "Origin");+ }+ // If no match, do NOT set the header — browser will block the request. res.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS"); res.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization"); if (req.method === "OPTIONS") return res.sendStatus(204); next(); }); +// ---------- Auth middleware ----------+// No-op in local-only mode (MCP_AUTH_TOKEN unset). Enforces 401 when token is set.+const MCP_TOKEN = process.env.MCP_AUTH_TOKEN || "";+function requireAuth(req, res, next) {+ if (req.path === "/health") return next();+ if (!MCP_TOKEN) return next();+ const expected = `Bearer ${MCP_TOKEN}`;+ const got = req.get("authorization") || "";+ const ok = got.length === expected.length &&+ timingSafeEqual(Buffer.from(got), Buffer.from(expected));+ if (!ok) return res.status(401).json({ error: "unauthorized" });+ next();+}+app.use(requireAuth);@@ -0,0 +1,88 @@+#!/usr/bin/env node+/**+ * ADR-166 §6 acceptance #9 — anti-regression lock for the MCP-bridge+ * security surface. Runs on the source of BOTH bridge files (the ruvocal+ * variant that ships in docker-compose, and the sibling REST bridge).+ *+ * Fails if any of the following load-bearing controls silently disappears:+ * 1. `MCP_BIND_HOST` default is `127.0.0.1` (loopback default — Phase 1a)+ * 2. Public bind without `MCP_AUTH_TOKEN` calls `process.exit(1)` at boot (Phase 1b)+ * 3. `requireAuth` middleware exists and is mounted with `app.use(requireAuth)` (Phase 1c)+ * 4. `requireAuth` uses `timingSafeEqual` (constant-time compare)+ * 5. `executeTool` denies terminal_execute unless `MCP_ENABLE_TERMINAL === "true"` (Phase 1d + 2a)+ * 6. CORS respects `MCP_CORS_ORIGIN` allowlist (Phase 3b)+ *+ * Runs offline (no server, no deps). CI-safe.+ *+ * node test-security-lock.js # → exit 0 on green, exit 1 on regression+ */++import fs from "node:fs";+import path from "node:path";+import { fileURLToPath } from "node:url";++const HERE = path.dirname(fileURLToPath(import.meta.url));+const BRIDGES = [+ path.join(HERE, "index.js"), // ruflo/src/mcp-bridge/index.js+ path.join(HERE, "..", "ruvocal", "mcp-bridge", "index.js"), // ruvocal variant (deployed)+];++const CHECKS = [+ {+ id: "1. bind-loopback-default",+ rx: /BIND_HOST\s*=\s*process\.env\.MCP_BIND_HOST\s*\|\|\s*["']127\.0\.0\.1["']/,+ hint: "MCP_BIND_HOST must default to 127.0.0.1 (ADR-166 §6 Phase 1a)",+ },+ {+ id: "2. fail-closed-public-bind",+ rx: /isPublic\s*&&\s*!process\.env\.MCP_AUTH_TOKEN[\s\S]*?process\.exit\(\s*1\s*\)/,+ hint: "public bind without MCP_AUTH_TOKEN must process.exit(1) at boot (Phase 1b)",+ },+ {+ id: "3. auth-middleware-mounted",+ rx: /app\.use\(\s*requireAuth\s*\)/,+ hint: "requireAuth middleware must be mounted via app.use() (Phase 1c)",+ },+ {+ id: "4. constant-time-token-compare",+ rx: /timingSafeEqual\s*\(/,+ hint: "auth compare must use timingSafeEqual (Phase 1c)",+ },+ {+ id: "5. terminal-gate-server-side",+ rx: /MCP_ENABLE_TERMINAL[\s\S]*?executeTool[\s\S]*?TOOL_DISABLED|executeTool[\s\S]*?MCP_ENABLE_TERMINAL[\s\S]*?TOOL_DISABLED/,+ hint: "terminal_execute must be denied at executeTool unless MCP_ENABLE_TERMINAL=true (Phase 1d + 2a)",+ },+ {+ id: "6. cors-allowlist-respected",+ rx: /MCP_CORS_ORIGIN[\s\S]*?CORS_ALLOWLIST/,+ hint: "CORS must respect MCP_CORS_ORIGIN allowlist (Phase 3b)",+ },+];++let hardFail = false;+for (const bridge of BRIDGES) {+ if (!fs.existsSync(bridge)) {+ console.error(`FAIL: bridge missing: ${bridge}`);+ hardFail = true;+ continue;+ }+ const src = fs.readFileSync(bridge, "utf-8");+ const rel = path.relative(path.join(HERE, "..", ".."), bridge);+ console.log(`\n# ${rel}`);+ for (const check of CHECKS) {+ if (check.rx.test(src)) {+ console.log(` ✓ ${check.id}`);+ } else {+ console.log(` ✗ ${check.id}`);+ console.log(` hint: ${check.hint}`);+ hardFail = true;+ }+ }+}++if (hardFail) {+ console.error("\nADR-166 security lock FAILED — a load-bearing control was removed or drifted.");+ process.exit(1);+}+console.log("\nADR-166 security lock: OK");AI-assisted change a6dbed8693931d7e125bfa4f2d8541076837bddd5712752e26079e7202a92f9d · Fix a066629842ba66004d3cdfbd50c54c8f1bd0c05b8fbbc5992697f151edbfeddc