Files
ruvnet__ruflo/ruflo/docker-compose.yml
rUv d00a0a40cd security(mcp-bridge): ADR-166 unauthenticated RCE — Phase 1-3 remediation + CI lock (#2521)
* docs(adr): ADR-166 — MCP bridge unauthenticated RCE remediation (coordinated disclosure)

Document and propose remediation for a coordinated-disclosure report: the
ruvocal MCP bridge exposes POST /mcp with no authentication and the shipping
docker-compose binds it to all interfaces, yielding unauthenticated RCE plus
LLM-key, learning-store, and MongoDB compromise.

Status: Proposed — no code changes. Captures confirmed findings (V1-V8) with
file:line evidence, the 8-step attack chain, gap analysis, a phased roadmap
(incident response + auth + hardening), acceptance criteria, and an evidence
ledger. Reporter identity, PoC callback domain, and target host are redacted.

Co-Authored-By: RuFlo <ruv@ruv.net>

* feat(security): ADR-166 reframe — local-only default, public is opt-in (V1/V6/V8)

Maintainer decision (2026-06-30): MCP design contract is local-only by
default. Public network exposure becomes an explicit opt-in deployment
pattern with mandatory auth + token.

## Defaults flipped to loopback

- `ruflo/docker-compose.yml`: bridge `3001:3001` → `127.0.0.1:3001:3001`;
  Mongo `27017:27017` → `127.0.0.1:27017:27017`
- Both bridges (`ruflo/src/{ruvocal/,}mcp-bridge/index.js`):
  `app.listen(PORT)` → `app.listen(PORT, BIND_HOST)` with
  `BIND_HOST = process.env.MCP_BIND_HOST || '127.0.0.1'`
- `v3/@claude-flow/plugin-agent-federation/src/bin.ts`:
  default `bindHost` `'0.0.0.0'` → `'127.0.0.1'`

## Optional public deployment pattern

NEW `ruflo/docker-compose.public.yml` — explicit override composition.
Operator must set BOTH `MCP_BIND_HOST=0.0.0.0` AND
`MCP_AUTH_TOKEN=$(openssl rand -base64 32)`, otherwise the bridge exits
non-zero at startup with a FATAL message that includes the token-gen
command.

When `MCP_AUTH_TOKEN` is set (any bind), the new `requireAuth`
middleware enforces `Authorization: Bearer <token>` on all `/mcp*`,
`/chat/completions`, `/autopilot*`, `/mcp-servers` routes using
`timingSafeEqual`. When unset (local-only mode), middleware is a no-op
— zero friction for the default deployment.

## Why this framing

Per the review-thread pushback:
> "If someone has unauthorized access to your environment, you have
>  bigger problems. The MCP is local so unauthorized is the only
>  option."

For the local CLI / loopback case this is correct — stdio IPC trust
flows from the spawning process. The previous always-on bearer auth
proposal was rejected as imposing local-IPC overhead on the common case.

The auth cost (token rotation, header injection in chat-ui) is now
concentrated entirely on operators who explicitly choose public
exposure — they pay the security cost only when they assume the risk.

## ADR-166 amendments (6 surgical edits)

- §1.2 — adds the design-contract clause
- §6 Phase 1 — retitled; 1a now leads with loopback default, 1b is
  fail-closed-on-opt-in, 1c is auth-when-token-set, 1d unchanged
- §6 new "Optional public deployment pattern" subsection with the
  generate-token + compose -f override recipe
- §7 R1/R2 — reframed to acknowledge the UX cost is concentrated in
  the optional path
- §8 — "bind loopback only; skip auth" REJECTED → "loopback default;
  auth for opt-in" ACCEPTED
- §10 — maintainer decision 2026-06-30 cited

## Gates (all PASS)

- `npm audit --audit-level=critical` root: exit 0
- `npm audit --audit-level=critical` v3:   exit 0
- CLI build: clean (tsc)
- Static smoke (both bridges): bind-host + token + fail-closed wired
- Compose YAML parses (default loopback + public override)

## Follow-ons (in same release train)

1. Chat-ui MCP_SERVERS Authorization header injection (Q1) —
   load-bearing when MCP_AUTH_TOKEN is set; tracked for same release.
2. MCP_ENABLE_TERMINAL gate (1d) — separate commit before merge.
3. Compose-merge smoke test for `ports: []` removal of Mongo host
   mapping in public override.

Co-Authored-By: Dragan Spiridonov <dragan@spiridonov.dev>
Co-Authored-By: RuFlo <ruv@ruv.net>
Claude-Session: https://claude.ai/code/session_01WbBa4nccx5aGhXphoHxcni

* security(mcp-bridge): ADR-166 Phase 1-3 — server-side tool gate, read-only rootfs, mongo auth, CI lock

Completes the ADR-166 remediation. Phase 1a/1b/1c (loopback default,
fail-closed public bind, bearer auth middleware) had already landed on
this branch — this commit closes the remaining acceptance criteria and
locks the surface with static + runtime CI gates.

Adds
- Server-side terminal gate (Phase 1d + 2a). `executeTool()` in BOTH
  bridges now denies `terminal_execute` (and any `*terminal_execute*`
  tool name) unless `MCP_ENABLE_TERMINAL=true`. Enforced at the same
  site every request path reaches (`/mcp`, `/mcp/:group`, autopilot),
  which was the missing link that made the disclosed unauth-RCE chain
  reach shell (V2/V3). Returns `{error, code: "TOOL_DISABLED"}`.
- Startup posture banner. Bridges log `[security] bind=… auth=… terminal=…`
  at listen time so operators see the security state on boot. If
  `MCP_ENABLE_TERMINAL=true`, an additional WARN line calls out the
  shell-in-container blast radius and points at ADR-166 §6 Phase 1d.
- MongoDB auth on by default (Phase 2b). `mongodb` service now runs
  `--auth`; `MONGO_INITDB_ROOT_PASSWORD` is a hard-required compose
  variable (the `${VAR:?…}` syntax refuses to start the stack if unset
  and prints the generation command). `chat-ui`'s MONGODB_URL is
  updated to include the credentials.
- Read-only rootfs on the bridge (Phase 2c). `read_only: true` +
  `tmpfs: /tmp` on the `mcp-bridge` service. Breaks step 7 of the
  disclosed PoC (the `/app/beacon.js` write fails EROFS; `NODE_OPTIONS`
  preload path can't be planted from inside the container).
- CORS allowlist wiring (Phase 3b). Both bridges honor
  `MCP_CORS_ORIGIN` (comma-separated). Default `*` for back-compat on
  loopback; when set to an allowlist, only listed origins get the
  header echoed back (plus `Vary: Origin`). Non-listed origins get
  nothing — browsers block the request.
- Anti-regression static-source lock (Phase §6 #9).
  `ruflo/src/mcp-bridge/test-security-lock.js` — 6 checks × 2 bridges,
  offline, zero-deps. Refuses to pass if any of the load-bearing
  controls silently disappears from source: loopback default,
  fail-closed public bind, `app.use(requireAuth)`, `timingSafeEqual`,
  terminal gate in `executeTool`, CORS allowlist honored.
- Runtime behavioral verification.
  `ruflo/src/mcp-bridge/test-runtime-security.mjs` spawns each bridge
  with a test env and asserts over the wire: R1 loopback bind, R2
  unauthenticated `POST /mcp` → 401, R3 authenticated → ≠401, R4
  `terminal_execute` → TOOL_DISABLED, R5 public bind + no token →
  exit ≠0 + FATAL to stderr. Complements the source-only lock.
- CI workflow `.github/workflows/adr-166-mcp-bridge-security.yml`.
  Four jobs on every PR touching bridge / compose / federation:
  (1) static-source lock, (2) runtime behavior on Node 22,
  (3) compose defaults (loopback binds + Mongo --auth + read_only),
  (4) plugin-agent-federation `bindHost` default is 127.0.0.1.

ADR update
- `ADR-166` status: Proposed → Accepted (Phase 0-3 shipped).
- Reporter acknowledgement pinned to the ADR authors block (name held
  until GHSA + CVE are published; will be credited by name there).
- §9.1 post-remediation table added — each of the 9 acceptance
  criteria mapped to the verification method and result.

Local verification (2026-06-30, before push)
- `node test-security-lock.js`         → 12/12 ✅ (6 × 2 bridges)
- `node test-runtime-security.mjs`     → 12/12 ✅ (R1-R5b × 2 bridges)

Explicitly deferred to a follow-up
- §6 Phase 3a (scoped per-backend env allowlist). Requires enumerating
  every backend's key requirements first; #6 in §9.1 acceptance is
  flagged ⏳ deferred rather than ✅.
- §7 Q1 (chat-ui → bridge token transport). Recommend Docker secret +
  nginx-injected header; unblocked by this commit but not landed here.
- §7 Q2 (npx/CLI bridge path shares the gate). Confirmed the source
  file exercised here IS the shipping one; the CLI code path also
  imports `app.use(requireAuth)` because it re-exports the same
  `main()`. Explicit CLI-path CI check to follow.

Contributor thanks
- The external security researcher (name held until embargo lifts)
  produced a full 8-step PoC + video that made the chain concrete
  end-to-end. Without step 5 (AgentDB pattern-store poisoning) we
  would have shipped only the network fix and skipped Phase 0c
  (audit + purge the learning store) which patched code alone does
  not cover.
- Dragan Spiridonov, co-author of ADR-166.

Co-Authored-By: RuFlo <ruv@ruv.net>

---------

Co-authored-by: Dragan Spiridonov <spiridonovdragan@gmail.com>
Co-authored-by: Dragan Spiridonov <dragan@spiridonov.dev>
2026-07-01 10:31:21 -04:00

122 lines
4.5 KiB
YAML

# RuFlo Chat UI (ruvocal fork) + MCP Bridge
# All secrets via environment variables. Never commit .env files with keys.
#
# Usage:
# cp .env.example .env # fill in your API keys
# docker compose up -d
services:
# --- MongoDB (ADR-166 §6 Phase 2b — auth on by default) ---
mongodb:
image: mongo:7
restart: unless-stopped
volumes:
- mongo-data:/data/db
ports:
- "127.0.0.1:27017:27017"
environment:
MONGO_INITDB_DATABASE: ${MONGODB_DB_NAME:-chat-db}
MONGO_INITDB_ROOT_USERNAME: ${MONGO_INITDB_ROOT_USERNAME:-ruflo}
MONGO_INITDB_ROOT_PASSWORD: ${MONGO_INITDB_ROOT_PASSWORD:?FATAL: MONGO_INITDB_ROOT_PASSWORD is required. Generate: MONGO_INITDB_ROOT_PASSWORD=$(openssl rand -base64 32)}
command: ["--auth"]
# --- MCP Bridge (ruvocal version — Gemini grounded search, autopilot SSE) ---
mcp-bridge:
build:
context: ./src/ruvocal/mcp-bridge
dockerfile: Dockerfile
restart: unless-stopped
ports:
- "127.0.0.1:3001:3001"
environment:
PORT: "3001"
NODE_ENV: production
# AI Provider Keys (required: at least one)
OPENAI_API_KEY: ${OPENAI_API_KEY:-}
GOOGLE_API_KEY: ${GOOGLE_API_KEY:-}
OPENROUTER_API_KEY: ${OPENROUTER_API_KEY:-}
# Backend service URLs (add your own)
SEARCH_API_URL: ${SEARCH_API_URL:-}
RESEARCH_API_URL: ${RESEARCH_API_URL:-}
# --- Tool Groups (MCP_GROUP_*) ---
# Default-on groups
MCP_GROUP_INTELLIGENCE: ${MCP_GROUP_INTELLIGENCE:-true}
MCP_GROUP_AGENTS: ${MCP_GROUP_AGENTS:-true}
MCP_GROUP_MEMORY: ${MCP_GROUP_MEMORY:-true}
MCP_GROUP_DEVTOOLS: ${MCP_GROUP_DEVTOOLS:-true}
# Opt-in groups
MCP_GROUP_SECURITY: ${MCP_GROUP_SECURITY:-false}
MCP_GROUP_BROWSER: ${MCP_GROUP_BROWSER:-false}
MCP_GROUP_NEURAL: ${MCP_GROUP_NEURAL:-false}
MCP_GROUP_AGENTIC_FLOW: ${MCP_GROUP_AGENTIC_FLOW:-false}
MCP_GROUP_CLAUDE_CODE: ${MCP_GROUP_CLAUDE_CODE:-false}
MCP_GROUP_GEMINI: ${MCP_GROUP_GEMINI:-false}
MCP_GROUP_CODEX: ${MCP_GROUP_CODEX:-false}
# API keys for optional groups
ANTHROPIC_API_KEY: ${ANTHROPIC_API_KEY:-}
# --- ADR-166 §6 security surface ---
MCP_BIND_HOST: ${MCP_BIND_HOST:-127.0.0.1}
MCP_AUTH_TOKEN: ${MCP_AUTH_TOKEN:-}
MCP_ENABLE_TERMINAL: ${MCP_ENABLE_TERMINAL:-false}
MCP_CORS_ORIGIN: ${MCP_CORS_ORIGIN:-*}
# ADR-166 §6 Phase 2c — read-only rootfs breaks the PoC's /app/beacon.js write.
read_only: true
tmpfs:
- /tmp
healthcheck:
test: ["CMD", "node", "-e", "fetch('http://localhost:3001/health').then(r => process.exit(r.ok ? 0 : 1))"]
interval: 30s
timeout: 5s
retries: 3
# --- Nginx reverse proxy (brand injection, CORS, static assets) ---
nginx:
build:
context: ./src/nginx
dockerfile: Dockerfile
restart: unless-stopped
ports:
- "3000:3000"
depends_on:
- chat-ui
- mcp-bridge
healthcheck:
test: ["CMD", "wget", "--spider", "-q", "http://localhost:3000"]
interval: 30s
timeout: 5s
retries: 3
# --- RuFlo Chat UI (ruvocal SvelteKit fork — built from source) ---
chat-ui:
build:
context: ./src/ruvocal
dockerfile: Dockerfile
args:
INCLUDE_DB: "false"
restart: unless-stopped
expose:
- "3000"
environment:
# Runtime config injected via DOTENV_LOCAL (entrypoint.sh writes .env.local)
DOTENV_LOCAL: |
MONGODB_URL=mongodb://${MONGO_INITDB_ROOT_USERNAME:-ruflo}:${MONGO_INITDB_ROOT_PASSWORD}@mongodb:27017/${MONGODB_DB_NAME:-chat-db}?authSource=admin
MONGODB_DB_NAME=${MONGODB_DB_NAME:-chat-db}
PUBLIC_APP_NAME=${BRAND_NAME:-RuFlo}
PUBLIC_APP_DESCRIPTION=${BRAND_DESCRIPTION:-Enterprise AI Agent Orchestration Platform}
PUBLIC_APP_ASSETS=chatui
PUBLIC_ORIGIN=
LLM_SUMMARIZATION=true
ENABLE_DATA_EXPORT=true
ALLOW_IFRAME=true
OPENAI_BASE_URL=http://mcp-bridge:3001
OPENAI_API_KEY=${OPENAI_API_KEY:-sk-placeholder}
MCP_SERVERS=[{"name":"Core Tools","url":"http://mcp-bridge:3001/mcp/core"},{"name":"Intelligence & Learning","url":"http://mcp-bridge:3001/mcp/intelligence"},{"name":"Agents & Orchestration","url":"http://mcp-bridge:3001/mcp/agents"},{"name":"Memory & Knowledge","url":"http://mcp-bridge:3001/mcp/memory"},{"name":"Dev Tools & Analysis","url":"http://mcp-bridge:3001/mcp/devtools"}]
COOKIE_SECURE=${COOKIE_SECURE:-false}
COOKIE_SAMESITE=${COOKIE_SAMESITE:-lax}
depends_on:
- mongodb
- mcp-bridge
volumes:
mongo-data: