Skip to content

Commit 42ce158

Browse files
authored
Merge pull request #3599 from ruvnet/dream/2026-10-01-security
fix(mcp): refuse unauthenticated HTTP on non-loopback hosts; optional bearer-token auth
2 parents c0811ec + f543594 commit 42ce158

7 files changed

Lines changed: 782 additions & 57 deletions

File tree

‎v3/@claude-flow/cli/README.md‎

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -193,6 +193,28 @@ npm install -g ruflo@latest
193193
claude mcp add claude-flow -- npx ruflo@latest mcp start
194194
```
195195

196+
#### HTTP transport security
197+
198+
`ruflo mcp start -t http` exposes every registered tool over HTTP (`/rpc`, `/mcp`, `/ws`). The default `stdio` transport is not affected.
199+
200+
| Setting | Effect |
201+
|---|---|
202+
| `RUFLO_MCP_HTTP_TOKEN=<token>` | Require `Authorization: Bearer <token>` on every request. Failure is a bare `401`. The token is compared in constant time and is never logged. Prefer this over `--auth-token`, which is visible in `ps`. |
203+
| `--auth-token-file <path>` | Read the token from a file (one line). Takes precedence over the env var. |
204+
| `--auth-token <token>` | Same, on the command line (visible in argv; highest precedence). |
205+
| `RUFLO_MCP_ALLOW_UNAUTHENTICATED_HTTP=1` | Allow an unauthenticated bind to a non-loopback `--host`. Only for a trusted network boundary or an authenticating reverse proxy. |
206+
207+
A token must be 16-512 printable ASCII characters (no spaces). `GET /health` stays public and returns only `{"status":"ok"}` when a token is set. Tokens are supported for `-t http` only; `-t websocket` rejects one.
208+
209+
| `--host` | no token | token set |
210+
|---|---|---|
211+
| `localhost`, `127.0.0.1`, `::1` | starts, **unauthenticated** | starts, token required |
212+
| anything else (`0.0.0.0`, `::`, a LAN IP, `127.0.0.2`, `[::1]`, `LOCALHOST`) | **refuses to start** unless `RUFLO_MCP_ALLOW_UNAUTHENTICATED_HTTP=1` | starts, token required |
213+
214+
Without a token, a loopback server is still unauthenticated: any process on the machine, and any web page that can reach it via DNS rebinding, can call tools. Set `RUFLO_MCP_HTTP_TOKEN` whenever the HTTP transport is used.
215+
216+
**Upgrading:** binding off loopback (for example `--host 0.0.0.0` in Docker) now needs a token or the opt-out above; otherwise the server exits with an error.
217+
196218
---
197219

198220
## What You Get
Lines changed: 227 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,227 @@
1+
// Dream Cycle 2026-10-01 (security) + follow-up: the MCP HTTP server
2+
// (`ruflo mcp start -t http`) used to start with no authentication at all on
3+
// any host. Two controls close that for the cases a configuration can express:
4+
//
5+
// 1. startHttpServer() refuses a NON-loopback --host unless the request path
6+
// is authenticated (RUFLO_MCP_HTTP_TOKEN / --auth-token-file) or the
7+
// operator explicitly sets RUFLO_MCP_ALLOW_UNAUTHENTICATED_HTTP=1.
8+
// 2. With a token, @claude-flow/mcp's HTTP transport requires
9+
// `Authorization: Bearer <token>` on every request.
10+
//
11+
// Loopback WITHOUT a token stays unauthenticated by design (any local process
12+
// can call tools) - that is documented, not closed.
13+
//
14+
// The wiring tests drive the real MCPServerManager.start() (the entry the CLI
15+
// uses) with @claude-flow/mcp mocked, so they assert the actual decision made
16+
// in startHttpServer: delete the `throw` there and the refusal tests fail.
17+
// Transport-level enforcement is tested in
18+
// v3/@claude-flow/mcp/__tests__/http-transport-auth.test.ts.
19+
20+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
21+
import * as fs from 'node:fs';
22+
import * as os from 'node:os';
23+
import * as path from 'node:path';
24+
25+
const createMCPServer = vi.fn();
26+
27+
vi.mock('@claude-flow/mcp', () => ({
28+
createMCPServer: (...args: unknown[]) => createMCPServer(...args),
29+
}));
30+
vi.mock('../src/mcp-client.js', () => ({
31+
listMCPTools: () => [],
32+
callMCPTool: async () => ({}),
33+
}));
34+
35+
import {
36+
MCPServerManager,
37+
isLoopbackHost,
38+
isUnauthenticatedHttpAllowed,
39+
resolveMcpHttpAuthToken,
40+
shouldRefuseUnauthenticatedHttp,
41+
} from '../src/mcp-server.js';
42+
43+
const TOKEN = 'correct-horse-battery-staple-42';
44+
const OPT_OUT = 'RUFLO_MCP_ALLOW_UNAUTHENTICATED_HTTP';
45+
46+
let tmp: string;
47+
let managers: MCPServerManager[] = [];
48+
const savedEnv = { ...process.env };
49+
50+
function lanIp(): string {
51+
for (const addrs of Object.values(os.networkInterfaces())) {
52+
for (const a of addrs ?? []) if (a.family === 'IPv4' && !a.internal) return a.address;
53+
}
54+
return '192.168.1.5';
55+
}
56+
57+
function manager(options: ConstructorParameters<typeof MCPServerManager>[0]): MCPServerManager {
58+
const m = new MCPServerManager({
59+
transport: 'http',
60+
port: 39000 + Math.floor(Math.random() * 1000),
61+
pidFile: path.join(tmp, 'mcp.pid'),
62+
logFile: path.join(tmp, 'mcp.log'),
63+
...options,
64+
});
65+
m.on('error', () => { /* start() rethrows; this only stops the EventEmitter 'error' rethrow */ });
66+
managers.push(m);
67+
return m;
68+
}
69+
70+
beforeEach(() => {
71+
tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'mcp-auth-gate-'));
72+
delete process.env[OPT_OUT];
73+
delete process.env.RUFLO_MCP_HTTP_TOKEN;
74+
createMCPServer.mockReset();
75+
createMCPServer.mockImplementation(() => ({
76+
registerTools: () => ({ failed: [] }),
77+
start: async () => {},
78+
stop: async () => {},
79+
}));
80+
});
81+
82+
afterEach(async () => {
83+
for (const m of managers) { try { await m.stop(); } catch { /* not started */ } }
84+
managers = [];
85+
process.env = { ...savedEnv };
86+
fs.rmSync(tmp, { recursive: true, force: true });
87+
});
88+
89+
describe('MCPServerManager.start() - non-loopback hosts are refused without auth', () => {
90+
const hosts = ['0.0.0.0', '::', lanIp()];
91+
92+
for (const host of hosts) {
93+
it(`refuses host ${host} with the documented message and never builds a server`, async () => {
94+
await expect(manager({ host }).start()).rejects.toThrow(/Refusing to start the MCP HTTP server on non-loopback host/);
95+
await expect(manager({ host }).start()).rejects.toThrow(/RUFLO_MCP_HTTP_TOKEN/);
96+
await expect(manager({ host }).start()).rejects.toThrow(/RUFLO_MCP_ALLOW_UNAUTHENTICATED_HTTP=1/);
97+
expect(createMCPServer).not.toHaveBeenCalled();
98+
});
99+
100+
it(`starts host ${host} when the explicit opt-out is set (no auth configured)`, async () => {
101+
process.env[OPT_OUT] = '1';
102+
await manager({ host }).start();
103+
expect(createMCPServer).toHaveBeenCalledTimes(1);
104+
expect(createMCPServer.mock.calls[0][0].auth).toBeUndefined();
105+
});
106+
107+
it(`starts host ${host} with a token and no opt-out, and wires the token into the transport`, async () => {
108+
await manager({ host, authToken: TOKEN }).start();
109+
expect(createMCPServer).toHaveBeenCalledTimes(1);
110+
const cfg = createMCPServer.mock.calls[0][0];
111+
expect(cfg.host).toBe(host);
112+
expect(cfg.auth).toEqual({ enabled: true, method: 'token', tokens: [TOKEN] });
113+
});
114+
}
115+
116+
it('an invalid opt-out value does not lift the gate', async () => {
117+
for (const v of ['0', 'false', 'yes', '']) {
118+
process.env[OPT_OUT] = v;
119+
await expect(manager({ host: '0.0.0.0' }).start()).rejects.toThrow(/Refusing to start/);
120+
}
121+
expect(createMCPServer).not.toHaveBeenCalled();
122+
});
123+
124+
it('a token with the standalone websocket transport is rejected (no working check there)', async () => {
125+
await expect(manager({ transport: 'websocket', host: '0.0.0.0', authToken: TOKEN }).start())
126+
.rejects.toThrow(/only supported with --transport http/);
127+
expect(createMCPServer).not.toHaveBeenCalled();
128+
});
129+
130+
it('websocket transport on a non-loopback host is still refused without the opt-out', async () => {
131+
await expect(manager({ transport: 'websocket', host: '0.0.0.0' }).start()).rejects.toThrow(/Refusing to start/);
132+
});
133+
});
134+
135+
describe('MCPServerManager.start() - loopback hosts start', () => {
136+
for (const host of ['localhost', '127.0.0.1', '::1']) {
137+
it(`starts ${host} with no token and no opt-out (unauthenticated, as documented)`, async () => {
138+
await manager({ host }).start();
139+
expect(createMCPServer).toHaveBeenCalledTimes(1);
140+
expect(createMCPServer.mock.calls[0][0].auth).toBeUndefined();
141+
});
142+
143+
it(`starts ${host} with a token and enforces it`, async () => {
144+
await manager({ host, authToken: TOKEN }).start();
145+
expect(createMCPServer.mock.calls[0][0].auth).toMatchObject({ enabled: true, tokens: [TOKEN] });
146+
});
147+
}
148+
149+
it('does not leak the token through the "starting" event', async () => {
150+
const m = manager({ host: '127.0.0.1', authToken: TOKEN });
151+
const seen: string[] = [];
152+
m.on('starting', (d) => seen.push(JSON.stringify(d)));
153+
await m.start();
154+
expect(seen.join('')).not.toContain(TOKEN);
155+
expect(seen.join('')).toContain('[redacted]');
156+
});
157+
});
158+
159+
describe('isLoopbackHost', () => {
160+
it('is true for exactly the three recognized loopback spellings', () => {
161+
for (const h of ['localhost', '127.0.0.1', '::1']) expect(isLoopbackHost(h)).toBe(true);
162+
});
163+
164+
it('fails closed for everything else, including other loopback spellings', () => {
165+
// 127.0.0.2, [::1], LOCALHOST and 0:0:0:0:0:0:0:1 are loopback to the OS
166+
// but are not normalized: they need the token or the opt-out.
167+
for (const h of ['0.0.0.0', '::', '192.168.1.5', '127.0.0.2', '[::1]', 'LOCALHOST', '0:0:0:0:0:0:0:1', '']) {
168+
expect(isLoopbackHost(h)).toBe(false);
169+
}
170+
});
171+
});
172+
173+
describe('isUnauthenticatedHttpAllowed', () => {
174+
it('only the documented values opt out', () => {
175+
expect(isUnauthenticatedHttpAllowed({})).toBe(false);
176+
for (const v of ['yes', '0', 'false', 'TRUE', ' 1']) {
177+
expect(isUnauthenticatedHttpAllowed({ [OPT_OUT]: v })).toBe(false);
178+
}
179+
expect(isUnauthenticatedHttpAllowed({ [OPT_OUT]: '1' })).toBe(true);
180+
expect(isUnauthenticatedHttpAllowed({ [OPT_OUT]: 'true' })).toBe(true);
181+
});
182+
});
183+
184+
describe('shouldRefuseUnauthenticatedHttp - the decision matrix', () => {
185+
it.each([
186+
// host, optOut, authenticated, refuse?
187+
['0.0.0.0', false, false, true],
188+
['0.0.0.0', true, false, false],
189+
['0.0.0.0', false, true, false],
190+
['0.0.0.0', true, true, false],
191+
['127.0.0.1', false, false, false],
192+
['localhost', false, false, false],
193+
['::1', false, false, false],
194+
['127.0.0.2', false, false, true],
195+
])('host=%s optOut=%s authenticated=%s -> refuse=%s', (host, optOut, authenticated, refuse) => {
196+
const env = optOut ? { [OPT_OUT]: '1' } : {};
197+
expect(shouldRefuseUnauthenticatedHttp(host, env, authenticated)).toBe(refuse);
198+
});
199+
});
200+
201+
describe('resolveMcpHttpAuthToken', () => {
202+
it('returns undefined when nothing is configured (env unset or empty)', () => {
203+
expect(resolveMcpHttpAuthToken({}, {})).toBeUndefined();
204+
expect(resolveMcpHttpAuthToken({}, { RUFLO_MCP_HTTP_TOKEN: '' })).toBeUndefined();
205+
});
206+
207+
it('reads the env var, a file (trailing newline stripped), or an explicit flag, in flag > file > env order', () => {
208+
expect(resolveMcpHttpAuthToken({}, { RUFLO_MCP_HTTP_TOKEN: TOKEN })).toBe(TOKEN);
209+
expect(resolveMcpHttpAuthToken({ tokenFile: '/x' }, { RUFLO_MCP_HTTP_TOKEN: 'e'.repeat(20) }, () => `${TOKEN}\n`)).toBe(TOKEN);
210+
expect(resolveMcpHttpAuthToken({ token: TOKEN, tokenFile: '/x' }, {}, () => 'f'.repeat(20))).toBe(TOKEN);
211+
});
212+
213+
it('rejects malformed tokens without echoing them (fail closed, never "no auth")', () => {
214+
for (const bad of ['short', 'has space in the middle of it', 'ünïcödé-token-ünïcödé-token', 'x'.repeat(513), 'tab\there-and-more-chars']) {
215+
let message = '';
216+
try { resolveMcpHttpAuthToken({}, { RUFLO_MCP_HTTP_TOKEN: bad }); } catch (e) { message = (e as Error).message; }
217+
expect(message).toMatch(/Invalid MCP HTTP auth token from RUFLO_MCP_HTTP_TOKEN/);
218+
expect(message).not.toContain(bad);
219+
}
220+
expect(() => resolveMcpHttpAuthToken({ token: '' }, {})).toThrow(/Invalid MCP HTTP auth token/);
221+
});
222+
223+
it('an unreadable token file is an error, not "no token"', () => {
224+
expect(() => resolveMcpHttpAuthToken({ tokenFile: '/nope' }, {}, () => { throw new Error('ENOENT /nope'); }))
225+
.toThrow(/Could not read the MCP HTTP auth token file/);
226+
});
227+
});

‎v3/@claude-flow/cli/__tests__/mcp-stdio-no-sibling-kill.test.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,8 @@ vi.mock('../src/mcp-server.js', () => ({
5757
getMCPServerStatus: vi.fn(async () => h.status),
5858
filterAdvertisedMcpTools: (tools: unknown[]) => tools,
5959
parseMcpToolSelection: () => 'all',
60+
resolveMcpHttpAuthToken: () => undefined,
61+
isLoopbackHost: () => true,
6062
}));
6163

6264
// mcp-client.js pulls in the full tool registry; the start path never needs it.

‎v3/@claude-flow/cli/src/commands/mcp.ts‎

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@ import {
1818
stopMCPServer,
1919
getMCPServerStatus,
2020
type MCPServerOptions,
21+
resolveMcpHttpAuthToken,
22+
isLoopbackHost,
2123
type MCPServerStatus,
2224
filterAdvertisedMcpTools,
2325
parseMcpToolSelection,
@@ -87,6 +89,16 @@ const startCommand: Command = {
8789
description: 'HTTP request timeout in milliseconds (default 30000; env RUFLO_MCP_REQUEST_TIMEOUT_MS)',
8890
type: 'number'
8991
},
92+
{
93+
name: 'auth-token-file',
94+
description: 'Read the HTTP bearer token from this file (http transport). Preferred over --auth-token; env RUFLO_MCP_HTTP_TOKEN also works',
95+
type: 'string'
96+
},
97+
{
98+
name: 'auth-token',
99+
description: 'HTTP bearer token (16+ printable ASCII chars). Visible in argv/ps - prefer RUFLO_MCP_HTTP_TOKEN or --auth-token-file',
100+
type: 'string'
101+
},
90102
{
91103
name: 'daemon',
92104
short: 'd',
@@ -106,6 +118,8 @@ const startCommand: Command = {
106118
{ command: 'claude-flow mcp start', description: 'Start with defaults (stdio)' },
107119
{ command: 'claude-flow mcp start -p 8080 -t http', description: 'Start HTTP server' },
108120
{ command: 'claude-flow mcp start -t http --request-timeout-ms 120000', description: 'Allow slower cold-start HTTP tools' },
121+
{ command: 'RUFLO_MCP_HTTP_TOKEN=<16+ chars> claude-flow mcp start -t http', description: 'HTTP server requiring "Authorization: Bearer <token>" on every request (except GET /health)' },
122+
{ command: 'RUFLO_MCP_HTTP_TOKEN=<16+ chars> claude-flow mcp start -t http --host 0.0.0.0', description: 'Non-loopback bind: allowed only with a token (or RUFLO_MCP_ALLOW_UNAUTHENTICATED_HTTP=1)' },
109123
{ command: 'claude-flow mcp start -d', description: 'Start as daemon' },
110124
{ command: 'claude-flow mcp start -f', description: 'Force restart (kill existing)' }
111125
],
@@ -125,6 +139,16 @@ const startCommand: Command = {
125139
output.printError('Request timeout must be an integer from 1 to 3600000 milliseconds');
126140
return { success: false, exitCode: 1 };
127141
}
142+
let authToken: string | undefined;
143+
try {
144+
authToken = resolveMcpHttpAuthToken({
145+
token: (ctx.flags.authToken ?? ctx.flags['auth-token']) as string | undefined,
146+
tokenFile: (ctx.flags.authTokenFile ?? ctx.flags['auth-token-file']) as string | undefined,
147+
});
148+
} catch (error) {
149+
output.printError((error as Error).message);
150+
return { success: false, exitCode: 1 };
151+
}
128152
const daemon = (ctx.flags.daemon as boolean) ?? false;
129153
const force = (ctx.flags.force as boolean) ?? false;
130154

@@ -186,6 +210,7 @@ const startCommand: Command = {
186210
port,
187211
tools: !tools || tools === 'all' ? 'all' : tools.split(','),
188212
...(requestTimeoutMs === undefined ? {} : { requestTimeoutMs }),
213+
...(authToken ? { authToken } : {}),
189214
daemonize: daemon,
190215
};
191216

@@ -245,6 +270,13 @@ const startCommand: Command = {
245270
if (transport === 'http') {
246271
output.writeln(output.dim(` Health: http://${host}:${port}/health`));
247272
output.writeln(output.dim(` RPC: http://${host}:${port}/rpc`));
273+
if (authToken) {
274+
output.writeln(output.dim(' Auth: bearer token required (Authorization: Bearer <token>); /health is public'));
275+
} else if (isLoopbackHost(host)) {
276+
output.printWarning('No auth token set: any local process (and DNS-rebinding web pages) can call tools. Set RUFLO_MCP_HTTP_TOKEN to require a bearer token.');
277+
} else {
278+
output.printWarning('Running UNAUTHENTICATED on a non-loopback host (RUFLO_MCP_ALLOW_UNAUTHENTICATED_HTTP is set).');
279+
}
248280
} else if (transport === 'websocket') {
249281
output.writeln(output.dim(` WebSocket: ws://${host}:${port}/ws`));
250282
}

0 commit comments

Comments
 (0)