From b3fc99097b76adf706cb0dca58f24d808f651339 Mon Sep 17 00:00:00 2001 From: Petrus Pennanen Date: Mon, 27 Jul 2026 18:13:10 +0200 Subject: [PATCH 1/2] fix(mcp): send gate bearer token on wake_remote and daemon calls wake_remote POSTed to ${gateUrl}/wake with no Authorization header, so any daemon with auth_token set answered 401 unauthorized (repro: Jul 26 wake toward the Mini gate at :8788). The local-daemon forwarding calls (/intents probe, /intent create, /intents decision poll) had the same omission and would break the moment the local daemon sets auth_token. Resolve the fleet token per request - ~/.config/iak-gate.token first (IAK_GATE_TOKEN_FILE overrides the path), then IAK_GATE_TOKEN env - and attach Authorization: Bearer when present. No token resolves to no header, so open daemons keep working unauthenticated. Per-request resolution means a token rotation never leaves a long-lived MCP session sending a stale credential. Local daemon calls prefer the config's own mcp.confirmations.auth_token. Tests: unit coverage for resolveGateToken/gateAuthHeaders plus stdio end-to-end regressions against a real HTTP gate asserting the bearer is sent when a token file exists and absent when none does. Co-Authored-By: Claude Fable 5 --- src/mcp-server.mjs | 44 ++++++++++-- test/mcp-server.test.mjs | 141 ++++++++++++++++++++++++++++++++++++++- 2 files changed, 178 insertions(+), 7 deletions(-) diff --git a/src/mcp-server.mjs b/src/mcp-server.mjs index d555470..9ff6825 100644 --- a/src/mcp-server.mjs +++ b/src/mcp-server.mjs @@ -21,6 +21,7 @@ import { execSync } from 'node:child_process'; import { readFileSync, writeFileSync, renameSync, statSync } from 'node:fs'; +import { homedir } from 'node:os'; import { fileURLToPath } from 'node:url'; import { dirname, join } from 'node:path'; import { Server } from '@modelcontextprotocol/sdk/server/index.js'; @@ -101,6 +102,28 @@ export function configuredAgentSessions(config) { return [...sessions]; } +// Gate daemons (startConfirmationsServer / iak-mcp-daemon) enforce their +// auth_token on EVERY endpoint, /wake included — a caller without the bearer +// header gets 401 {"ok":false,"error":"unauthorized"} even for a plain nudge. +// Resolve this machine's copy of the fleet token: the token file first +// (~/.config/iak-gate.token, overridable via IAK_GATE_TOKEN_FILE), then the +// IAK_GATE_TOKEN env var. Returns '' when neither exists so open daemons keep +// working unauthenticated. Callers re-resolve per request rather than caching +// at boot — a token rotation must not leave a long-lived MCP session sending +// a stale credential (the poller stale-key incident shape). +export function resolveGateToken({ env = process.env } = {}) { + const tokenFile = env.IAK_GATE_TOKEN_FILE || join(homedir(), '.config', 'iak-gate.token'); + try { + const fromFile = readFileSync(tokenFile, 'utf8').trim(); + if (fromFile) return fromFile; + } catch { /* no token file */ } + return typeof env.IAK_GATE_TOKEN === 'string' ? env.IAK_GATE_TOKEN.trim() : ''; +} + +export function gateAuthHeaders(token) { + return token ? { Authorization: `Bearer ${token}` } : {}; +} + export function confirmationFromHandle(args = {}, config = {}) { const explicit = args.fromHandle || args.from_handle; if (typeof explicit === 'string' && explicit.trim()) return explicit.trim(); @@ -364,9 +387,17 @@ export async function runMcpServer({ configPath } = {}) { const daemonHost = confirmCfg.host || '127.0.0.1'; const daemonPort = confirmCfg.port || 8788; const daemonBase = `http://${daemonHost === '0.0.0.0' ? '127.0.0.1' : daemonHost}:${daemonPort}`; + // The local daemon enforces the same bearer gate when its auth_token is + // set; our own config value is authoritative for it, with the fleet token + // as fallback. + const daemonAuthHeaders = () => gateAuthHeaders(confirmCfg.auth_token || resolveGateToken()); let daemonAvailable = false; try { - const probe = await fetch(`${daemonBase}/intents`, { method: 'GET', signal: AbortSignal.timeout(500) }); + const probe = await fetch(`${daemonBase}/intents`, { + method: 'GET', + headers: daemonAuthHeaders(), + signal: AbortSignal.timeout(500), + }); daemonAvailable = probe.ok; } catch { /* not running */ } @@ -473,7 +504,10 @@ export async function runMcpServer({ configPath } = {}) { 'runs its configured wake script (typically scripts/claudemb-wake.sh, an osascript ' + 'injector for the Claude desktop app) so the remote agent gets a "check rooms" prompt ' + 'within ~500ms regardless of room-poll cadence. Use this for direct cross-machine ' + - 'agent-to-agent coordination (e.g. claudemm has a question that needs claudemb).', + 'agent-to-agent coordination (e.g. claudemm has a question that needs claudemb). ' + + 'Token-protected daemons are handled automatically: the request carries ' + + 'Authorization: Bearer from ~/.config/iak-gate.token (or IAK_GATE_TOKEN_FILE / ' + + 'IAK_GATE_TOKEN env) when one exists; without a token the request stays unauthenticated.', inputSchema: { type: 'object', properties: { @@ -688,7 +722,7 @@ export async function runMcpServer({ configPath } = {}) { try { const res = await fetch(`${args.gateUrl}/wake`, { method: 'POST', - headers: { 'Content-Type': 'application/json' }, + headers: { 'Content-Type': 'application/json', ...gateAuthHeaders(resolveGateToken()) }, body: JSON.stringify({ text }), signal: AbortSignal.timeout(5000), }); @@ -756,7 +790,7 @@ export async function runMcpServer({ configPath } = {}) { if (daemonAvailable) { const createRes = await fetch(`${daemonBase}/intent`, { method: 'POST', - headers: { 'Content-Type': 'application/json' }, + headers: { 'Content-Type': 'application/json', ...daemonAuthHeaders() }, body: JSON.stringify({ prompt: args.prompt, session: args.session, @@ -772,7 +806,7 @@ export async function runMcpServer({ configPath } = {}) { while (Date.now() < deadline) { await new Promise(r => setTimeout(r, 1000)); try { - const list = await (await fetch(`${daemonBase}/intents`)).json(); + const list = await (await fetch(`${daemonBase}/intents`, { headers: daemonAuthHeaders() })).json(); const found = list.find((i) => i.id === id); if (found && found.status === 'decided') { return ok(JSON.stringify({ id, decision: found.decision }, null, 2)); diff --git a/test/mcp-server.test.mjs b/test/mcp-server.test.mjs index c3fc3fa..9ace5b3 100644 --- a/test/mcp-server.test.mjs +++ b/test/mcp-server.test.mjs @@ -19,6 +19,8 @@ import { roomApiConfigured, removeConsumedNotifications, ackNotificationFile, + resolveGateToken, + gateAuthHeaders, } from '../src/mcp-server.mjs'; const __dirname = dirname(fileURLToPath(import.meta.url)); @@ -152,6 +154,7 @@ test('roomApiConfigured: requires both API key and room', () => { import { writeFileSync, appendFileSync, readFileSync, mkdtempSync, rmSync } from 'node:fs'; import { tmpdir } from 'node:os'; +import { createServer } from 'node:http'; function rpc(line) { return JSON.stringify(line) + '\n'; } @@ -229,6 +232,57 @@ test('iak-mcp.mjs with room API config exposes low-latency room tools', async () } }); +// --- resolveGateToken -------------------------------------------------------- + +test('resolveGateToken: reads the token file named by IAK_GATE_TOKEN_FILE', () => { + const dir = mkdtempSync(join(tmpdir(), 'iak-gate-token-')); + const tokenFile = join(dir, 'gate.token'); + try { + writeFileSync(tokenFile, 'file-secret-1\n'); + assert.equal(resolveGateToken({ env: { IAK_GATE_TOKEN_FILE: tokenFile } }), 'file-secret-1'); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +test('resolveGateToken: token file wins over IAK_GATE_TOKEN env', () => { + const dir = mkdtempSync(join(tmpdir(), 'iak-gate-token-')); + const tokenFile = join(dir, 'gate.token'); + try { + writeFileSync(tokenFile, 'file-secret-2\n'); + assert.equal( + resolveGateToken({ env: { IAK_GATE_TOKEN_FILE: tokenFile, IAK_GATE_TOKEN: 'env-secret' } }), + 'file-secret-2' + ); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +test('resolveGateToken: missing file falls back to IAK_GATE_TOKEN env', () => { + const dir = mkdtempSync(join(tmpdir(), 'iak-gate-token-')); + try { + const env = { IAK_GATE_TOKEN_FILE: join(dir, 'does-not-exist'), IAK_GATE_TOKEN: 'env-secret' }; + assert.equal(resolveGateToken({ env }), 'env-secret'); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +test('resolveGateToken: no file and no env resolves to empty (open daemons keep working)', () => { + const dir = mkdtempSync(join(tmpdir(), 'iak-gate-token-')); + try { + assert.equal(resolveGateToken({ env: { IAK_GATE_TOKEN_FILE: join(dir, 'does-not-exist') } }), ''); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +test('gateAuthHeaders: bearer header only when a token exists', () => { + assert.deepEqual(gateAuthHeaders('tok'), { Authorization: 'Bearer tok' }); + assert.deepEqual(gateAuthHeaders(''), {}); +}); + // --- removeConsumedNotifications (pure) -------------------------------------- test('removeConsumedNotifications: consumed prefix is dropped, later append survives', () => { @@ -324,8 +378,11 @@ test('ackNotificationFile: missing notification file is a no-op ack', () => { // --- end-to-end stdio regression: room_list_new → append → room_ack --------- -function bootMcp(configPath) { - const child = spawn('node', [BIN, '--config', configPath], { stdio: ['pipe', 'pipe', 'pipe'] }); +function bootMcp(configPath, { env } = {}) { + const child = spawn('node', [BIN, '--config', configPath], { + stdio: ['pipe', 'pipe', 'pipe'], + env: env ? { ...process.env, ...env } : process.env, + }); let buf = ''; const waiters = new Map(); child.stdout.on('data', (b) => { @@ -389,3 +446,83 @@ test('iak-mcp.mjs REGRESSION: room_ack clears only what room_list_new returned', rmSync(dir, { recursive: true, force: true }); } }); + +// --- end-to-end stdio regression: wake_remote gate auth ---------------------- + +// A tiny stand-in for a remote IAK daemon whose auth_token is set: every +// request must carry the exact bearer or it 401s, mirroring +// startConfirmationsServer's gate. +function bootFakeGate(requiredToken) { + const seen = []; + const server = createServer((req, res) => { + let body = ''; + req.on('data', (c) => { body += c; }); + req.on('end', () => { + seen.push({ path: req.url, auth: req.headers.authorization || null, body }); + const authed = !requiredToken || req.headers.authorization === `Bearer ${requiredToken}`; + res.writeHead(authed ? 202 : 401, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify(authed ? { ok: true } : { ok: false, error: 'unauthorized' })); + }); + }); + return new Promise((resolvePromise) => { + server.listen(0, '127.0.0.1', () => { + resolvePromise({ + url: `http://127.0.0.1:${server.address().port}`, + seen, + close: () => new Promise((r) => server.close(r)), + }); + }); + }); +} + +test('iak-mcp.mjs REGRESSION: wake_remote sends Authorization: Bearer when a gate token file exists', async () => { + const dir = mkdtempSync(join(tmpdir(), 'iak-mcp-test-')); + const cfgPath = join(dir, 'config.json'); + const tokenFile = join(dir, 'gate.token'); + writeFileSync(cfgPath, JSON.stringify({ tmux: { allow: [], default_session: 't' } })); + writeFileSync(tokenFile, 'gate-secret-1\n'); + const gate = await bootFakeGate('gate-secret-1'); + const { request, close } = bootMcp(cfgPath, { env: { IAK_GATE_TOKEN_FILE: tokenFile, IAK_GATE_TOKEN: '' } }); + try { + const res = await request('tools/call', { + name: 'wake_remote', + arguments: { gateUrl: gate.url, text: 'check rooms' }, + }); + const text = res.result.content[0].text; + assert.match(text, /202/, `expected a 202 wake, got: ${text}`); + const wake = gate.seen.find((r) => r.path === '/wake'); + assert.ok(wake, 'expected the gate to receive POST /wake'); + assert.equal(wake.auth, 'Bearer gate-secret-1'); + assert.equal(JSON.parse(wake.body).text, 'check rooms'); + } finally { + await close(); + await gate.close(); + rmSync(dir, { recursive: true, force: true }); + } +}); + +test('iak-mcp.mjs: wake_remote stays unauthenticated when no gate token exists', async () => { + const dir = mkdtempSync(join(tmpdir(), 'iak-mcp-test-')); + const cfgPath = join(dir, 'config.json'); + writeFileSync(cfgPath, JSON.stringify({ tmux: { allow: [], default_session: 't' } })); + const gate = await bootFakeGate(''); // open daemon — no token required + const { request, close } = bootMcp(cfgPath, { + // Point the file lookup at a path that cannot exist and clear the env + // fallback so the test is independent of the host's real fleet token. + env: { IAK_GATE_TOKEN_FILE: join(dir, 'no-such-token'), IAK_GATE_TOKEN: '' }, + }); + try { + const res = await request('tools/call', { + name: 'wake_remote', + arguments: { gateUrl: gate.url }, + }); + assert.match(res.result.content[0].text, /202/); + const wake = gate.seen.find((r) => r.path === '/wake'); + assert.ok(wake, 'expected the gate to receive POST /wake'); + assert.equal(wake.auth, null, 'open daemons must not receive a bearer header'); + } finally { + await close(); + await gate.close(); + rmSync(dir, { recursive: true, force: true }); + } +}); From bebac4e7772ef7ae3bfd6dfc0fbd52d186a01b2f Mon Sep 17 00:00:00 2001 From: Petrus Pennanen Date: Mon, 27 Jul 2026 18:23:40 +0200 Subject: [PATCH 2/2] fix(mcp): never send the gate token to untrusted wake_remote destinations claudemm's PR #52 review found the blocker: gateUrl is a caller-supplied free-form string, and tool args are reachable from untrusted input, so attaching the fleet bearer to an arbitrary destination is a token- exfiltration path. The bearer now only accompanies requests whose host a fleet daemon can plausibly live on - loopback, RFC1918, the tailnet CGNAT range, or .local names; anything else still gets the wake, just unauthenticated (the same graceful degradation open daemons use). Tests assert no Authorization header travels to off-allowlist or unparseable destinations, and that the Mini/loopback/LAN cases still carry it. 204 pass. Co-Authored-By: Claude Fable 5 --- src/mcp-server.mjs | 36 +++++++++++++++++++++++++++++++++++- test/mcp-server.test.mjs | 27 +++++++++++++++++++++++++++ 2 files changed, 62 insertions(+), 1 deletion(-) diff --git a/src/mcp-server.mjs b/src/mcp-server.mjs index 9ff6825..12453ca 100644 --- a/src/mcp-server.mjs +++ b/src/mcp-server.mjs @@ -124,6 +124,40 @@ export function gateAuthHeaders(token) { return token ? { Authorization: `Bearer ${token}` } : {}; } +// The fleet gate token must never travel to a caller-influenced destination: +// tool args are reachable from untrusted input (room messages, web pages, PR +// text can steer a wake_remote call), so an arbitrary gateUrl + bearer header +// is a token-exfiltration path (claudemm, PR #52 review). Trusted hosts are +// where fleet daemons can actually live: loopback, RFC1918 LAN ranges, the +// tailnet CGNAT range, and .local mDNS names. Anything else still gets the +// wake — just unauthenticated, the same graceful degradation open daemons +// already rely on. +export function isTrustedGateHost(hostname) { + const h = String(hostname || '').toLowerCase().replace(/^\[|\]$/g, ''); + if (h === 'localhost' || h === '::1') return true; + if (h.endsWith('.local')) return true; + const m = h.match(/^(\d+)\.(\d+)\.(\d+)\.(\d+)$/); + if (!m) return false; + const [a, b] = [Number(m[1]), Number(m[2])]; + if (a === 127) return true; // loopback + if (a === 10) return true; // RFC1918 + if (a === 172 && b >= 16 && b <= 31) return true; // RFC1918 + if (a === 192 && b === 168) return true; // RFC1918 + if (a === 100 && b >= 64 && b <= 127) return true; // tailnet CGNAT + return false; +} + +// Auth headers for a request to `url`: bearer only when the destination host +// is trusted, {} otherwise (and {} on any unparseable URL). +export function gateAuthHeadersFor(url, token = resolveGateToken()) { + try { + if (!isTrustedGateHost(new URL(url).hostname)) return {}; + } catch { + return {}; + } + return gateAuthHeaders(token); +} + export function confirmationFromHandle(args = {}, config = {}) { const explicit = args.fromHandle || args.from_handle; if (typeof explicit === 'string' && explicit.trim()) return explicit.trim(); @@ -722,7 +756,7 @@ export async function runMcpServer({ configPath } = {}) { try { const res = await fetch(`${args.gateUrl}/wake`, { method: 'POST', - headers: { 'Content-Type': 'application/json', ...gateAuthHeaders(resolveGateToken()) }, + headers: { 'Content-Type': 'application/json', ...gateAuthHeadersFor(args.gateUrl) }, body: JSON.stringify({ text }), signal: AbortSignal.timeout(5000), }); diff --git a/test/mcp-server.test.mjs b/test/mcp-server.test.mjs index 9ace5b3..e94eaee 100644 --- a/test/mcp-server.test.mjs +++ b/test/mcp-server.test.mjs @@ -21,6 +21,8 @@ import { ackNotificationFile, resolveGateToken, gateAuthHeaders, + gateAuthHeadersFor, + isTrustedGateHost, } from '../src/mcp-server.mjs'; const __dirname = dirname(fileURLToPath(import.meta.url)); @@ -283,6 +285,31 @@ test('gateAuthHeaders: bearer header only when a token exists', () => { assert.deepEqual(gateAuthHeaders(''), {}); }); +// The exfiltration guard (claudemm, PR #52 review): the fleet token must only +// ever accompany requests to hosts a fleet daemon can live on. A caller- +// influenced gateUrl outside those ranges gets NO Authorization header. +test('isTrustedGateHost: fleet-plausible hosts only', () => { + for (const h of ['localhost', '127.0.0.1', '::1', '10.0.0.5', '172.16.0.2', + '172.31.255.1', '192.168.50.240', '100.97.140.13', '100.64.0.1', 'mini.local']) { + assert.equal(isTrustedGateHost(h), true, h); + } + for (const h of ['evil.example.com', '8.8.8.8', '172.15.0.1', '172.32.0.1', + '100.63.0.1', '100.128.0.1', '193.168.1.1', 'local.evil.com', '', undefined]) { + assert.equal(isTrustedGateHost(h), false, String(h)); + } +}); + +test('gateAuthHeadersFor: NO bearer to off-allowlist or unparseable destinations', () => { + assert.deepEqual(gateAuthHeadersFor('https://evil.example.com/wake', 'tok'), {}); + assert.deepEqual(gateAuthHeadersFor('http://8.8.8.8:8788', 'tok'), {}); + assert.deepEqual(gateAuthHeadersFor('not a url', 'tok'), {}); + assert.deepEqual(gateAuthHeadersFor('http://100.97.140.13:8788', 'tok'), + { Authorization: 'Bearer tok' }); + assert.deepEqual(gateAuthHeadersFor('http://127.0.0.1:8788', 'tok'), + { Authorization: 'Bearer tok' }); + assert.deepEqual(gateAuthHeadersFor('http://192.168.50.240:8788', ''), {}); +}); + // --- removeConsumedNotifications (pure) -------------------------------------- test('removeConsumedNotifications: consumed prefix is dropped, later append survives', () => {