mirror of
https://github.com/ruvnet/ruflo.git
synced 2026-09-14 14:01:28 +08:00
d00a0a40cd
* 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>
95 lines
3.8 KiB
YAML
95 lines
3.8 KiB
YAML
name: ADR-166 MCP Bridge Security Lock
|
|
|
|
# Anti-regression gate for the ADR-166 remediation. Runs on every PR touching
|
|
# either bridge file, the shared docker-compose, or the security tests. Fails
|
|
# closed if any load-bearing control drifts.
|
|
|
|
on:
|
|
push:
|
|
branches: [main]
|
|
paths:
|
|
- 'ruflo/src/mcp-bridge/**'
|
|
- 'ruflo/src/ruvocal/mcp-bridge/**'
|
|
- 'ruflo/docker-compose.yml'
|
|
- 'ruflo/docker-compose.public.yml'
|
|
- 'v3/@claude-flow/plugin-agent-federation/src/bin.ts'
|
|
- '.github/workflows/adr-166-mcp-bridge-security.yml'
|
|
pull_request:
|
|
paths:
|
|
- 'ruflo/src/mcp-bridge/**'
|
|
- 'ruflo/src/ruvocal/mcp-bridge/**'
|
|
- 'ruflo/docker-compose.yml'
|
|
- 'ruflo/docker-compose.public.yml'
|
|
- 'v3/@claude-flow/plugin-agent-federation/src/bin.ts'
|
|
- '.github/workflows/adr-166-mcp-bridge-security.yml'
|
|
workflow_dispatch:
|
|
|
|
jobs:
|
|
static-source-lock:
|
|
name: Static-source security lock
|
|
runs-on: ubuntu-latest
|
|
steps:
|
|
- uses: actions/checkout@v4
|
|
- uses: actions/setup-node@v4
|
|
with:
|
|
node-version: 22
|
|
- name: ADR-166 §6 #9 — anti-regression lock (both bridges)
|
|
run: node ruflo/src/mcp-bridge/test-security-lock.js
|
|
|
|
runtime-behavior:
|
|
name: Runtime behavior — 401 + terminal gate + fail-closed
|
|
runs-on: ubuntu-latest
|
|
steps:
|
|
- uses: actions/checkout@v4
|
|
- uses: actions/setup-node@v4
|
|
with:
|
|
node-version: 22
|
|
- name: Install both bridges
|
|
run: |
|
|
(cd ruflo/src/mcp-bridge && npm install --no-audit --no-fund)
|
|
(cd ruflo/src/ruvocal/mcp-bridge && npm install --no-audit --no-fund)
|
|
- name: ADR-166 runtime verification
|
|
run: node ruflo/src/mcp-bridge/test-runtime-security.mjs
|
|
|
|
compose-loopback-only:
|
|
name: Compose default binds loopback + Mongo has auth
|
|
runs-on: ubuntu-latest
|
|
steps:
|
|
- uses: actions/checkout@v4
|
|
- name: Assert docker-compose binds ONLY to 127.0.0.1
|
|
run: |
|
|
set -euo pipefail
|
|
# No line may map a container port to a public host port.
|
|
# Look for "N.N.N.N:PORT:PORT" or bare "PORT:PORT" patterns under `ports:`.
|
|
if grep -nE '^\s*-\s+"(?!127\.0\.0\.1|0\.0\.0\.0)?[^"]*3001:3001"' ruflo/docker-compose.yml \
|
|
| grep -v '127.0.0.1:3001'; then
|
|
echo "::error::docker-compose.yml exposes 3001 on non-loopback interface"
|
|
exit 1
|
|
fi
|
|
if grep -nE '^\s*-\s+"(?!127\.0\.0\.1|0\.0\.0\.0)?[^"]*27017:27017"' ruflo/docker-compose.yml \
|
|
| grep -v '127.0.0.1:27017'; then
|
|
echo "::error::docker-compose.yml exposes 27017 on non-loopback interface"
|
|
exit 1
|
|
fi
|
|
# Mongo must run with --auth AND require MONGO_INITDB_ROOT_PASSWORD to boot.
|
|
grep -q '"--auth"' ruflo/docker-compose.yml \
|
|
|| { echo "::error::mongodb service missing --auth (Phase 2b)"; exit 1; }
|
|
grep -q 'MONGO_INITDB_ROOT_PASSWORD:\?' ruflo/docker-compose.yml \
|
|
|| { echo "::error::MONGO_INITDB_ROOT_PASSWORD must be a required variable (:? syntax)"; exit 1; }
|
|
grep -q 'read_only: true' ruflo/docker-compose.yml \
|
|
|| { echo "::error::bridge service must be read_only: true (Phase 2c)"; exit 1; }
|
|
echo "compose defaults: OK"
|
|
|
|
federation-loopback-default:
|
|
name: plugin-agent-federation bindHost default
|
|
runs-on: ubuntu-latest
|
|
steps:
|
|
- uses: actions/checkout@v4
|
|
- name: Assert bindHost defaults to 127.0.0.1
|
|
run: |
|
|
set -euo pipefail
|
|
grep -q "bindHost: process.env.FEDERATION_BIND_HOST ?? '127.0.0.1'" \
|
|
v3/@claude-flow/plugin-agent-federation/src/bin.ts \
|
|
|| { echo "::error::plugin-agent-federation bindHost must default to 127.0.0.1 (Phase 3d)"; exit 1; }
|
|
echo "federation default: OK"
|