|
| 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 | +}); |
0 commit comments