ruflo 3.16.1 → 3.16.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/mcp-bridge/index.js +73 -5
- package/src/mcp-bridge/test-runtime-security.mjs +177 -0
- package/src/mcp-bridge/test-security-lock.js +88 -0
- package/src/ruvocal/mcp-bridge/.claude-flow/data/pending-insights.jsonl +3 -0
- package/src/ruvocal/mcp-bridge/.claude-flow/neural/stats.json +6 -0
- package/src/ruvocal/mcp-bridge/index.js +78 -5
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ruflo",
|
|
3
|
-
"version": "3.16.
|
|
3
|
+
"version": "3.16.3",
|
|
4
4
|
"description": "Ruflo - Enterprise AI agent orchestration platform. Deploy 60+ specialized agents in coordinated swarms with self-learning, fault-tolerant consensus, vector memory, and MCP integration",
|
|
5
5
|
"main": "bin/ruflo.js",
|
|
6
6
|
"type": "module",
|
package/src/mcp-bridge/index.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import express from "express";
|
|
2
2
|
import { spawn } from "child_process";
|
|
3
|
-
import { randomUUID } from "crypto";
|
|
3
|
+
import { randomUUID, timingSafeEqual } from "crypto";
|
|
4
4
|
|
|
5
5
|
// =============================================================================
|
|
6
6
|
// CONFIGURATION
|
|
@@ -12,6 +12,7 @@ const CLOUD_FUNCTIONS = {
|
|
|
12
12
|
};
|
|
13
13
|
|
|
14
14
|
const PORT = parseInt(process.env.PORT || "3001", 10);
|
|
15
|
+
const BIND_HOST = process.env.MCP_BIND_HOST || "127.0.0.1";
|
|
15
16
|
|
|
16
17
|
// =============================================================================
|
|
17
18
|
// TOOL GROUPS — Enable/disable categories of tools independently
|
|
@@ -771,7 +772,30 @@ async function executeGoapSearch(query, args) {
|
|
|
771
772
|
// TOOL EXECUTOR
|
|
772
773
|
// =============================================================================
|
|
773
774
|
|
|
775
|
+
// ADR-166 §6 Phase 1d + 2a — server-side tool gate.
|
|
776
|
+
// Enforced HERE (not just in the autopilot handler) so /mcp, /mcp/:group,
|
|
777
|
+
// autopilot, and any future path share ONE denial gate. Was the missing
|
|
778
|
+
// link that made the disclosed unauthenticated-RCE chain reach shell.
|
|
779
|
+
const DANGEROUS_TOOLS = Object.freeze(new Set([
|
|
780
|
+
"terminal_execute",
|
|
781
|
+
"ruflo__terminal_execute",
|
|
782
|
+
"devtools__terminal_execute",
|
|
783
|
+
]));
|
|
784
|
+
function isTerminalTool(name) {
|
|
785
|
+
return DANGEROUS_TOOLS.has(name) || /terminal_execute/i.test(name);
|
|
786
|
+
}
|
|
787
|
+
const MCP_ENABLE_TERMINAL = process.env.MCP_ENABLE_TERMINAL === "true";
|
|
788
|
+
|
|
774
789
|
async function executeTool(name, args) {
|
|
790
|
+
// Deny dangerous tools unless the operator explicitly opted in.
|
|
791
|
+
// Enforced on every path (not just autopilot) — root cause of ADR-166 V2/V3.
|
|
792
|
+
if (isTerminalTool(name) && !MCP_ENABLE_TERMINAL) {
|
|
793
|
+
return {
|
|
794
|
+
error:
|
|
795
|
+
`Tool "${name}" is disabled by default. Set MCP_ENABLE_TERMINAL=true to allow.`,
|
|
796
|
+
code: "TOOL_DISABLED",
|
|
797
|
+
};
|
|
798
|
+
}
|
|
775
799
|
// Validate that search-like tools have a non-empty query to prevent 400 errors
|
|
776
800
|
if (!args || typeof args !== "object") args = {};
|
|
777
801
|
const rawQuery = args.query ?? args.q ?? args.input ?? "";
|
|
@@ -858,15 +882,39 @@ const GROUP_DISPLAY_NAMES = {
|
|
|
858
882
|
const app = express();
|
|
859
883
|
app.use(express.json({ limit: "10mb" }));
|
|
860
884
|
|
|
861
|
-
// ---------- CORS middleware ----------
|
|
885
|
+
// ---------- CORS middleware (ADR-166 §6 Phase 3b) ----------
|
|
886
|
+
const CORS_ALLOWLIST = (process.env.MCP_CORS_ORIGIN || "*")
|
|
887
|
+
.split(",").map(s => s.trim()).filter(Boolean);
|
|
888
|
+
const CORS_WILDCARD = CORS_ALLOWLIST.length === 1 && CORS_ALLOWLIST[0] === "*";
|
|
862
889
|
app.use((req, res, next) => {
|
|
863
|
-
|
|
890
|
+
const origin = req.get("origin") || "";
|
|
891
|
+
if (CORS_WILDCARD) {
|
|
892
|
+
res.setHeader("Access-Control-Allow-Origin", "*");
|
|
893
|
+
} else if (origin && CORS_ALLOWLIST.includes(origin)) {
|
|
894
|
+
res.setHeader("Access-Control-Allow-Origin", origin);
|
|
895
|
+
res.setHeader("Vary", "Origin");
|
|
896
|
+
}
|
|
864
897
|
res.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS");
|
|
865
898
|
res.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization");
|
|
866
899
|
if (req.method === "OPTIONS") return res.sendStatus(204);
|
|
867
900
|
next();
|
|
868
901
|
});
|
|
869
902
|
|
|
903
|
+
// ---------- Auth middleware ----------
|
|
904
|
+
// No-op in local-only mode (MCP_AUTH_TOKEN unset). Enforces 401 when token is set.
|
|
905
|
+
const MCP_TOKEN = process.env.MCP_AUTH_TOKEN || "";
|
|
906
|
+
function requireAuth(req, res, next) {
|
|
907
|
+
if (req.path === "/health") return next();
|
|
908
|
+
if (!MCP_TOKEN) return next();
|
|
909
|
+
const expected = `Bearer ${MCP_TOKEN}`;
|
|
910
|
+
const got = req.get("authorization") || "";
|
|
911
|
+
const ok = got.length === expected.length &&
|
|
912
|
+
timingSafeEqual(Buffer.from(got), Buffer.from(expected));
|
|
913
|
+
if (!ok) return res.status(401).json({ error: "unauthorized" });
|
|
914
|
+
next();
|
|
915
|
+
}
|
|
916
|
+
app.use(requireAuth);
|
|
917
|
+
|
|
870
918
|
// ---------- Shared MCP handler ----------
|
|
871
919
|
function createMcpHandler(groupName) {
|
|
872
920
|
return async (req, res) => {
|
|
@@ -1676,10 +1724,30 @@ app.get("/groups", (_, res) => {
|
|
|
1676
1724
|
// =============================================================================
|
|
1677
1725
|
|
|
1678
1726
|
async function main() {
|
|
1679
|
-
|
|
1680
|
-
|
|
1727
|
+
const isPublic = BIND_HOST !== "127.0.0.1" && BIND_HOST !== "localhost";
|
|
1728
|
+
if (isPublic && !process.env.MCP_AUTH_TOKEN) {
|
|
1729
|
+
console.error(
|
|
1730
|
+
"FATAL: refusing to bind a public interface without MCP_AUTH_TOKEN. " +
|
|
1731
|
+
"Generate one with: MCP_AUTH_TOKEN=$(openssl rand -base64 32)"
|
|
1732
|
+
);
|
|
1733
|
+
process.exit(1);
|
|
1734
|
+
}
|
|
1735
|
+
app.listen(PORT, BIND_HOST, () => {
|
|
1736
|
+
console.log(`MCP Bridge v2.0.0 on port ${PORT} (${BIND_HOST})`);
|
|
1681
1737
|
const enabled = Object.entries(TOOL_GROUPS).filter(([, g]) => g.enabled).map(([n]) => n);
|
|
1682
1738
|
console.log(`Active groups: ${enabled.join(", ")}`);
|
|
1739
|
+
// ADR-166 §6 — startup posture banner
|
|
1740
|
+
console.log(
|
|
1741
|
+
`[security] bind=${BIND_HOST} auth=${process.env.MCP_AUTH_TOKEN ? "bearer" : "off (local-only)"} ` +
|
|
1742
|
+
`terminal=${MCP_ENABLE_TERMINAL ? "ENABLED (⚠ opt-in)" : "disabled"}`,
|
|
1743
|
+
);
|
|
1744
|
+
if (MCP_ENABLE_TERMINAL) {
|
|
1745
|
+
console.warn(
|
|
1746
|
+
"[security] WARNING: terminal_execute is enabled. This tool grants shell access " +
|
|
1747
|
+
"inside the bridge container to any client the auth layer accepts. Ensure " +
|
|
1748
|
+
"MCP_AUTH_TOKEN is set on any non-loopback bind. See ADR-166 §6 Phase 1d.",
|
|
1749
|
+
);
|
|
1750
|
+
}
|
|
1683
1751
|
});
|
|
1684
1752
|
|
|
1685
1753
|
const anyBackendNeeded = BACKEND_DEFS.some(isBackendNeeded);
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* ADR-166 runtime verification — spawn each bridge with a test env and
|
|
4
|
+
* assert the load-bearing behaviors observed OVER THE WIRE. Complements
|
|
5
|
+
* test-security-lock.js (which is static-source only).
|
|
6
|
+
*
|
|
7
|
+
* Behaviors verified:
|
|
8
|
+
* R1. bridge starts on 127.0.0.1 by default
|
|
9
|
+
* R2. with MCP_AUTH_TOKEN set: unauthenticated POST /mcp → 401
|
|
10
|
+
* R3. with MCP_AUTH_TOKEN set: authenticated POST /mcp → 200 (or ≠401)
|
|
11
|
+
* R4. terminal_execute call → TOOL_DISABLED error unless MCP_ENABLE_TERMINAL=true
|
|
12
|
+
* R5. public bind without MCP_AUTH_TOKEN → process exits ≠0 within 3s
|
|
13
|
+
*
|
|
14
|
+
* node test-runtime-security.mjs
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { spawn } from "node:child_process";
|
|
18
|
+
import { setTimeout as sleep } from "node:timers/promises";
|
|
19
|
+
import path from "node:path";
|
|
20
|
+
import { fileURLToPath } from "node:url";
|
|
21
|
+
|
|
22
|
+
const HERE = path.dirname(fileURLToPath(import.meta.url));
|
|
23
|
+
const BRIDGES = [
|
|
24
|
+
{ label: "src/mcp-bridge", entry: path.join(HERE, "index.js") },
|
|
25
|
+
{ label: "src/ruvocal/mcp-bridge", entry: path.join(HERE, "..", "ruvocal", "mcp-bridge", "index.js") },
|
|
26
|
+
];
|
|
27
|
+
|
|
28
|
+
const TOKEN = "test-token-" + Math.random().toString(36).slice(2);
|
|
29
|
+
let passed = 0, failed = 0;
|
|
30
|
+
const failures = [];
|
|
31
|
+
|
|
32
|
+
function assert(cond, label) {
|
|
33
|
+
if (cond) { console.log(` ✓ ${label}`); passed++; }
|
|
34
|
+
else { console.log(` ✗ ${label}`); failures.push(label); failed++; }
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
async function pickPort() {
|
|
38
|
+
// Pick a random port in the ephemeral range and hope for the best;
|
|
39
|
+
// if the bridge fails to bind, the fetch will time out and we fail.
|
|
40
|
+
return 20000 + Math.floor(Math.random() * 10000);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
async function startBridge(entry, env, { waitMs = 1500 } = {}) {
|
|
44
|
+
const child = spawn("node", [entry], {
|
|
45
|
+
env: { ...process.env, ...env },
|
|
46
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
47
|
+
});
|
|
48
|
+
let stdout = "", stderr = "";
|
|
49
|
+
child.stdout.on("data", (d) => { stdout += d.toString(); });
|
|
50
|
+
child.stderr.on("data", (d) => { stderr += d.toString(); });
|
|
51
|
+
const exitPromise = new Promise((resolve) => {
|
|
52
|
+
child.on("exit", (code) => resolve({ exited: true, code }));
|
|
53
|
+
});
|
|
54
|
+
const ready = Promise.race([
|
|
55
|
+
exitPromise,
|
|
56
|
+
sleep(waitMs).then(() => ({ exited: false, code: null })),
|
|
57
|
+
]);
|
|
58
|
+
return { child, stdout: () => stdout, stderr: () => stderr, ready };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
async function killAndWait(child) {
|
|
62
|
+
if (child.exitCode !== null) return;
|
|
63
|
+
try { child.kill("SIGTERM"); } catch { /* ignore */ }
|
|
64
|
+
await sleep(100);
|
|
65
|
+
if (child.exitCode === null) try { child.kill("SIGKILL"); } catch { /* ignore */ }
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
async function testBridge(label, entry) {
|
|
69
|
+
console.log(`\n# ${label}`);
|
|
70
|
+
|
|
71
|
+
// R1 + R2 + R3 + R4 — bridge starts with token, auth behavior + terminal gate
|
|
72
|
+
{
|
|
73
|
+
const port = await pickPort();
|
|
74
|
+
const b = await startBridge(entry, {
|
|
75
|
+
PORT: String(port),
|
|
76
|
+
MCP_BIND_HOST: "127.0.0.1",
|
|
77
|
+
MCP_AUTH_TOKEN: TOKEN,
|
|
78
|
+
MCP_ENABLE_TERMINAL: "false",
|
|
79
|
+
MCP_GROUP_DEVTOOLS: "false",
|
|
80
|
+
MCP_GROUP_INTELLIGENCE: "false",
|
|
81
|
+
MCP_GROUP_AGENTS: "false",
|
|
82
|
+
MCP_GROUP_MEMORY: "false",
|
|
83
|
+
});
|
|
84
|
+
const state = await b.ready;
|
|
85
|
+
if (state.exited) {
|
|
86
|
+
assert(false, `R1. bridge starts on 127.0.0.1 (exited early, code=${state.code}; stderr=${b.stderr().slice(0, 200)})`);
|
|
87
|
+
} else {
|
|
88
|
+
assert(true, "R1. bridge starts on 127.0.0.1 (didn't exit within 1.5s)");
|
|
89
|
+
|
|
90
|
+
// R2 — unauthenticated POST /mcp → 401
|
|
91
|
+
try {
|
|
92
|
+
const r = await fetch(`http://127.0.0.1:${port}/mcp`, {
|
|
93
|
+
method: "POST",
|
|
94
|
+
headers: { "Content-Type": "application/json" },
|
|
95
|
+
body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/list" }),
|
|
96
|
+
});
|
|
97
|
+
assert(r.status === 401, `R2. unauthenticated POST /mcp → 401 (got ${r.status})`);
|
|
98
|
+
} catch (e) {
|
|
99
|
+
assert(false, `R2. unauthenticated POST /mcp threw: ${e.message}`);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// R3 — authenticated POST /mcp → not 401
|
|
103
|
+
try {
|
|
104
|
+
const r = await fetch(`http://127.0.0.1:${port}/mcp`, {
|
|
105
|
+
method: "POST",
|
|
106
|
+
headers: {
|
|
107
|
+
"Content-Type": "application/json",
|
|
108
|
+
"Authorization": `Bearer ${TOKEN}`,
|
|
109
|
+
},
|
|
110
|
+
body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/list" }),
|
|
111
|
+
});
|
|
112
|
+
assert(r.status !== 401, `R3. authenticated POST /mcp → ≠401 (got ${r.status})`);
|
|
113
|
+
} catch (e) {
|
|
114
|
+
assert(false, `R3. authenticated POST /mcp threw: ${e.message}`);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// R4 — terminal_execute → TOOL_DISABLED (denied at executeTool, not just autopilot)
|
|
118
|
+
try {
|
|
119
|
+
const r = await fetch(`http://127.0.0.1:${port}/mcp`, {
|
|
120
|
+
method: "POST",
|
|
121
|
+
headers: {
|
|
122
|
+
"Content-Type": "application/json",
|
|
123
|
+
"Authorization": `Bearer ${TOKEN}`,
|
|
124
|
+
},
|
|
125
|
+
body: JSON.stringify({
|
|
126
|
+
jsonrpc: "2.0", id: 1, method: "tools/call",
|
|
127
|
+
params: { name: "terminal_execute", arguments: { command: "id" } },
|
|
128
|
+
}),
|
|
129
|
+
});
|
|
130
|
+
const body = await r.json();
|
|
131
|
+
// The response wraps the executeTool result in `result.content[0].text` as JSON string.
|
|
132
|
+
const text = JSON.stringify(body);
|
|
133
|
+
assert(text.includes("TOOL_DISABLED") || text.includes("disabled by default"),
|
|
134
|
+
"R4. terminal_execute denied server-side (TOOL_DISABLED)");
|
|
135
|
+
} catch (e) {
|
|
136
|
+
assert(false, `R4. terminal_execute threw: ${e.message}`);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
await killAndWait(b.child);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// R5 — public bind without token → process exits ≠0 within 3s
|
|
143
|
+
{
|
|
144
|
+
const port = await pickPort();
|
|
145
|
+
const b = await startBridge(entry, {
|
|
146
|
+
PORT: String(port),
|
|
147
|
+
MCP_BIND_HOST: "0.0.0.0",
|
|
148
|
+
MCP_AUTH_TOKEN: "",
|
|
149
|
+
MCP_GROUP_DEVTOOLS: "false",
|
|
150
|
+
MCP_GROUP_INTELLIGENCE: "false",
|
|
151
|
+
MCP_GROUP_AGENTS: "false",
|
|
152
|
+
MCP_GROUP_MEMORY: "false",
|
|
153
|
+
}, { waitMs: 3000 });
|
|
154
|
+
const state = await b.ready;
|
|
155
|
+
assert(state.exited && state.code !== 0,
|
|
156
|
+
`R5. public bind without token exits ≠0 (exited=${state.exited}, code=${state.code})`);
|
|
157
|
+
if (b.stderr().includes("FATAL")) {
|
|
158
|
+
assert(true, "R5b. FATAL message logged to stderr");
|
|
159
|
+
} else {
|
|
160
|
+
assert(false, "R5b. FATAL message expected in stderr");
|
|
161
|
+
}
|
|
162
|
+
await killAndWait(b.child);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
(async () => {
|
|
167
|
+
for (const { label, entry } of BRIDGES) {
|
|
168
|
+
await testBridge(label, entry);
|
|
169
|
+
}
|
|
170
|
+
console.log(`\n${passed} passed, ${failed} failed`);
|
|
171
|
+
if (failed > 0) {
|
|
172
|
+
console.log("\nFailures:");
|
|
173
|
+
for (const f of failures) console.log(` - ${f}`);
|
|
174
|
+
process.exit(1);
|
|
175
|
+
}
|
|
176
|
+
console.log("ADR-166 runtime verification: OK");
|
|
177
|
+
})();
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* ADR-166 §6 acceptance #9 — anti-regression lock for the MCP-bridge
|
|
4
|
+
* security surface. Runs on the source of BOTH bridge files (the ruvocal
|
|
5
|
+
* variant that ships in docker-compose, and the sibling REST bridge).
|
|
6
|
+
*
|
|
7
|
+
* Fails if any of the following load-bearing controls silently disappears:
|
|
8
|
+
* 1. `MCP_BIND_HOST` default is `127.0.0.1` (loopback default — Phase 1a)
|
|
9
|
+
* 2. Public bind without `MCP_AUTH_TOKEN` calls `process.exit(1)` at boot (Phase 1b)
|
|
10
|
+
* 3. `requireAuth` middleware exists and is mounted with `app.use(requireAuth)` (Phase 1c)
|
|
11
|
+
* 4. `requireAuth` uses `timingSafeEqual` (constant-time compare)
|
|
12
|
+
* 5. `executeTool` denies terminal_execute unless `MCP_ENABLE_TERMINAL === "true"` (Phase 1d + 2a)
|
|
13
|
+
* 6. CORS respects `MCP_CORS_ORIGIN` allowlist (Phase 3b)
|
|
14
|
+
*
|
|
15
|
+
* Runs offline (no server, no deps). CI-safe.
|
|
16
|
+
*
|
|
17
|
+
* node test-security-lock.js # → exit 0 on green, exit 1 on regression
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import fs from "node:fs";
|
|
21
|
+
import path from "node:path";
|
|
22
|
+
import { fileURLToPath } from "node:url";
|
|
23
|
+
|
|
24
|
+
const HERE = path.dirname(fileURLToPath(import.meta.url));
|
|
25
|
+
const BRIDGES = [
|
|
26
|
+
path.join(HERE, "index.js"), // ruflo/src/mcp-bridge/index.js
|
|
27
|
+
path.join(HERE, "..", "ruvocal", "mcp-bridge", "index.js"), // ruvocal variant (deployed)
|
|
28
|
+
];
|
|
29
|
+
|
|
30
|
+
const CHECKS = [
|
|
31
|
+
{
|
|
32
|
+
id: "1. bind-loopback-default",
|
|
33
|
+
rx: /BIND_HOST\s*=\s*process\.env\.MCP_BIND_HOST\s*\|\|\s*["']127\.0\.0\.1["']/,
|
|
34
|
+
hint: "MCP_BIND_HOST must default to 127.0.0.1 (ADR-166 §6 Phase 1a)",
|
|
35
|
+
},
|
|
36
|
+
{
|
|
37
|
+
id: "2. fail-closed-public-bind",
|
|
38
|
+
rx: /isPublic\s*&&\s*!process\.env\.MCP_AUTH_TOKEN[\s\S]*?process\.exit\(\s*1\s*\)/,
|
|
39
|
+
hint: "public bind without MCP_AUTH_TOKEN must process.exit(1) at boot (Phase 1b)",
|
|
40
|
+
},
|
|
41
|
+
{
|
|
42
|
+
id: "3. auth-middleware-mounted",
|
|
43
|
+
rx: /app\.use\(\s*requireAuth\s*\)/,
|
|
44
|
+
hint: "requireAuth middleware must be mounted via app.use() (Phase 1c)",
|
|
45
|
+
},
|
|
46
|
+
{
|
|
47
|
+
id: "4. constant-time-token-compare",
|
|
48
|
+
rx: /timingSafeEqual\s*\(/,
|
|
49
|
+
hint: "auth compare must use timingSafeEqual (Phase 1c)",
|
|
50
|
+
},
|
|
51
|
+
{
|
|
52
|
+
id: "5. terminal-gate-server-side",
|
|
53
|
+
rx: /MCP_ENABLE_TERMINAL[\s\S]*?executeTool[\s\S]*?TOOL_DISABLED|executeTool[\s\S]*?MCP_ENABLE_TERMINAL[\s\S]*?TOOL_DISABLED/,
|
|
54
|
+
hint: "terminal_execute must be denied at executeTool unless MCP_ENABLE_TERMINAL=true (Phase 1d + 2a)",
|
|
55
|
+
},
|
|
56
|
+
{
|
|
57
|
+
id: "6. cors-allowlist-respected",
|
|
58
|
+
rx: /MCP_CORS_ORIGIN[\s\S]*?CORS_ALLOWLIST/,
|
|
59
|
+
hint: "CORS must respect MCP_CORS_ORIGIN allowlist (Phase 3b)",
|
|
60
|
+
},
|
|
61
|
+
];
|
|
62
|
+
|
|
63
|
+
let hardFail = false;
|
|
64
|
+
for (const bridge of BRIDGES) {
|
|
65
|
+
if (!fs.existsSync(bridge)) {
|
|
66
|
+
console.error(`FAIL: bridge missing: ${bridge}`);
|
|
67
|
+
hardFail = true;
|
|
68
|
+
continue;
|
|
69
|
+
}
|
|
70
|
+
const src = fs.readFileSync(bridge, "utf-8");
|
|
71
|
+
const rel = path.relative(path.join(HERE, "..", ".."), bridge);
|
|
72
|
+
console.log(`\n# ${rel}`);
|
|
73
|
+
for (const check of CHECKS) {
|
|
74
|
+
if (check.rx.test(src)) {
|
|
75
|
+
console.log(` ✓ ${check.id}`);
|
|
76
|
+
} else {
|
|
77
|
+
console.log(` ✗ ${check.id}`);
|
|
78
|
+
console.log(` hint: ${check.hint}`);
|
|
79
|
+
hardFail = true;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
if (hardFail) {
|
|
85
|
+
console.error("\nADR-166 security lock FAILED — a load-bearing control was removed or drifted.");
|
|
86
|
+
process.exit(1);
|
|
87
|
+
}
|
|
88
|
+
console.log("\nADR-166 security lock: OK");
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
{"type":"edit","file":"/Users/cohen/Projects/ruflo/.github/workflows/adr-166-mcp-bridge-security.yml","timestamp":1782913295523}
|
|
2
|
+
{"type":"edit","file":"/Users/cohen/Projects/ruflo/v3/docs/adr/ADR-166-mcp-bridge-unauthenticated-rce-remediation.md","timestamp":1782913317258}
|
|
3
|
+
{"type":"edit","file":"/Users/cohen/Projects/ruflo/v3/docs/adr/ADR-166-mcp-bridge-unauthenticated-rce-remediation.md","timestamp":1782913344234}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import express from "express";
|
|
2
2
|
import { spawn } from "child_process";
|
|
3
|
-
import { randomUUID } from "crypto";
|
|
3
|
+
import { randomUUID, timingSafeEqual } from "crypto";
|
|
4
4
|
|
|
5
5
|
// =============================================================================
|
|
6
6
|
// CONFIGURATION
|
|
@@ -12,6 +12,7 @@ const CLOUD_FUNCTIONS = {
|
|
|
12
12
|
};
|
|
13
13
|
|
|
14
14
|
const PORT = parseInt(process.env.PORT || "3001", 10);
|
|
15
|
+
const BIND_HOST = process.env.MCP_BIND_HOST || "127.0.0.1";
|
|
15
16
|
|
|
16
17
|
// =============================================================================
|
|
17
18
|
// TOOL GROUPS — Enable/disable categories of tools independently
|
|
@@ -942,7 +943,30 @@ async function executeGoapSearchGemini(query, args) {
|
|
|
942
943
|
// TOOL EXECUTOR
|
|
943
944
|
// =============================================================================
|
|
944
945
|
|
|
946
|
+
// ADR-166 §6 Phase 1d + 2a — server-side tool gate.
|
|
947
|
+
// Enforced HERE (not just in the autopilot handler) so /mcp, /mcp/:group,
|
|
948
|
+
// autopilot, and any future path share ONE denial gate. Was the missing
|
|
949
|
+
// link that made the disclosed unauthenticated-RCE chain reach shell.
|
|
950
|
+
const DANGEROUS_TOOLS = Object.freeze(new Set([
|
|
951
|
+
"terminal_execute",
|
|
952
|
+
"ruflo__terminal_execute",
|
|
953
|
+
"devtools__terminal_execute",
|
|
954
|
+
]));
|
|
955
|
+
function isTerminalTool(name) {
|
|
956
|
+
return DANGEROUS_TOOLS.has(name) || /terminal_execute/i.test(name);
|
|
957
|
+
}
|
|
958
|
+
const MCP_ENABLE_TERMINAL = process.env.MCP_ENABLE_TERMINAL === "true";
|
|
959
|
+
|
|
945
960
|
async function executeTool(name, args) {
|
|
961
|
+
// Deny dangerous tools unless the operator explicitly opted in.
|
|
962
|
+
// Enforced on every path (not just autopilot) — root cause of ADR-166 V2/V3.
|
|
963
|
+
if (isTerminalTool(name) && !MCP_ENABLE_TERMINAL) {
|
|
964
|
+
return {
|
|
965
|
+
error:
|
|
966
|
+
`Tool "${name}" is disabled by default. Set MCP_ENABLE_TERMINAL=true to allow.`,
|
|
967
|
+
code: "TOOL_DISABLED",
|
|
968
|
+
};
|
|
969
|
+
}
|
|
946
970
|
switch (name) {
|
|
947
971
|
case "search": {
|
|
948
972
|
if (CLOUD_FUNCTIONS.search) {
|
|
@@ -1033,15 +1057,44 @@ const GROUP_DISPLAY_NAMES = {
|
|
|
1033
1057
|
const app = express();
|
|
1034
1058
|
app.use(express.json({ limit: "10mb" }));
|
|
1035
1059
|
|
|
1036
|
-
// ---------- CORS middleware ----------
|
|
1060
|
+
// ---------- CORS middleware (ADR-166 §6 Phase 3b) ----------
|
|
1061
|
+
// MCP_CORS_ORIGIN: comma-separated allowlist (e.g. "https://a.example,https://b.example").
|
|
1062
|
+
// unset → "*" for back-compat (loopback default is same-origin anyway)
|
|
1063
|
+
// "*" → wildcard (explicit opt-in — same as legacy behavior)
|
|
1064
|
+
// other → echo the request origin ONLY if it appears in the allowlist
|
|
1065
|
+
const CORS_ALLOWLIST = (process.env.MCP_CORS_ORIGIN || "*")
|
|
1066
|
+
.split(",").map(s => s.trim()).filter(Boolean);
|
|
1067
|
+
const CORS_WILDCARD = CORS_ALLOWLIST.length === 1 && CORS_ALLOWLIST[0] === "*";
|
|
1037
1068
|
app.use((req, res, next) => {
|
|
1038
|
-
|
|
1069
|
+
const origin = req.get("origin") || "";
|
|
1070
|
+
if (CORS_WILDCARD) {
|
|
1071
|
+
res.setHeader("Access-Control-Allow-Origin", "*");
|
|
1072
|
+
} else if (origin && CORS_ALLOWLIST.includes(origin)) {
|
|
1073
|
+
res.setHeader("Access-Control-Allow-Origin", origin);
|
|
1074
|
+
res.setHeader("Vary", "Origin");
|
|
1075
|
+
}
|
|
1076
|
+
// If no match, do NOT set the header — browser will block the request.
|
|
1039
1077
|
res.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS");
|
|
1040
1078
|
res.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization");
|
|
1041
1079
|
if (req.method === "OPTIONS") return res.sendStatus(204);
|
|
1042
1080
|
next();
|
|
1043
1081
|
});
|
|
1044
1082
|
|
|
1083
|
+
// ---------- Auth middleware ----------
|
|
1084
|
+
// No-op in local-only mode (MCP_AUTH_TOKEN unset). Enforces 401 when token is set.
|
|
1085
|
+
const MCP_TOKEN = process.env.MCP_AUTH_TOKEN || "";
|
|
1086
|
+
function requireAuth(req, res, next) {
|
|
1087
|
+
if (req.path === "/health") return next();
|
|
1088
|
+
if (!MCP_TOKEN) return next();
|
|
1089
|
+
const expected = `Bearer ${MCP_TOKEN}`;
|
|
1090
|
+
const got = req.get("authorization") || "";
|
|
1091
|
+
const ok = got.length === expected.length &&
|
|
1092
|
+
timingSafeEqual(Buffer.from(got), Buffer.from(expected));
|
|
1093
|
+
if (!ok) return res.status(401).json({ error: "unauthorized" });
|
|
1094
|
+
next();
|
|
1095
|
+
}
|
|
1096
|
+
app.use(requireAuth);
|
|
1097
|
+
|
|
1045
1098
|
// ---------- Shared MCP handler ----------
|
|
1046
1099
|
function createMcpHandler(groupName) {
|
|
1047
1100
|
return async (req, res) => {
|
|
@@ -1886,10 +1939,30 @@ app.get("/groups", (_, res) => {
|
|
|
1886
1939
|
// =============================================================================
|
|
1887
1940
|
|
|
1888
1941
|
async function main() {
|
|
1889
|
-
|
|
1890
|
-
|
|
1942
|
+
const isPublic = BIND_HOST !== "127.0.0.1" && BIND_HOST !== "localhost";
|
|
1943
|
+
if (isPublic && !process.env.MCP_AUTH_TOKEN) {
|
|
1944
|
+
console.error(
|
|
1945
|
+
"FATAL: refusing to bind a public interface without MCP_AUTH_TOKEN. " +
|
|
1946
|
+
"Generate one with: MCP_AUTH_TOKEN=$(openssl rand -base64 32)"
|
|
1947
|
+
);
|
|
1948
|
+
process.exit(1);
|
|
1949
|
+
}
|
|
1950
|
+
app.listen(PORT, BIND_HOST, () => {
|
|
1951
|
+
console.log(`MCP Bridge v2.0.0 on port ${PORT} (${BIND_HOST})`);
|
|
1891
1952
|
const enabled = Object.entries(TOOL_GROUPS).filter(([, g]) => g.enabled).map(([n]) => n);
|
|
1892
1953
|
console.log(`Active groups: ${enabled.join(", ")}`);
|
|
1954
|
+
// ADR-166 §6 — startup posture banner (helps operators see the security state at boot)
|
|
1955
|
+
console.log(
|
|
1956
|
+
`[security] bind=${BIND_HOST} auth=${process.env.MCP_AUTH_TOKEN ? "bearer" : "off (local-only)"} ` +
|
|
1957
|
+
`terminal=${MCP_ENABLE_TERMINAL ? "ENABLED (⚠ opt-in)" : "disabled"}`,
|
|
1958
|
+
);
|
|
1959
|
+
if (MCP_ENABLE_TERMINAL) {
|
|
1960
|
+
console.warn(
|
|
1961
|
+
"[security] WARNING: terminal_execute is enabled. This tool grants shell access " +
|
|
1962
|
+
"inside the bridge container to any client the auth layer accepts. Ensure " +
|
|
1963
|
+
"MCP_AUTH_TOKEN is set on any non-loopback bind. See ADR-166 §6 Phase 1d.",
|
|
1964
|
+
);
|
|
1965
|
+
}
|
|
1893
1966
|
});
|
|
1894
1967
|
|
|
1895
1968
|
const anyBackendNeeded = BACKEND_DEFS.some(isBackendNeeded);
|