From af995db864b29d7a67c7e302da5204d47a517119 Mon Sep 17 00:00:00 2001 From: Den <2119348+dzianisv@users.noreply.github.com> Date: Tue, 11 Aug 2026 20:09:21 +0000 Subject: [PATCH 1/2] fix: authenticate streamable HTTP MCP (#132) --- README.md | 19 +++- scripts/e2e-http-streamable.mjs | 178 ++++++++++++++++++++++++++------ src/cli.ts | 3 + src/server.ts | 64 +++++++++++- src/types.ts | 2 + 5 files changed, 232 insertions(+), 34 deletions(-) diff --git a/README.md b/README.md index 28e2e44..1c7479d 100644 --- a/README.md +++ b/README.md @@ -424,7 +424,23 @@ npx -y @vibebrowser/mcp@latest start --transport http Defaults: `--host 127.0.0.1`, `--http-port 8788`, `--http-path /mcp`. Add `--allow-host ` (repeatable) if you front it with a proxy or bind it -beyond localhost. +beyond localhost. Loopback bindings are auth-free by default. A non-loopback +binding refuses to start unless `--http-bearer-token` or +`VIBE_MCP_HTTP_BEARER_TOKEN` supplies a non-empty token. Prefer the environment +variable so the token does not appear in process arguments: + +```bash +export VIBE_MCP_HTTP_BEARER_TOKEN="$(openssl rand -hex 32)" +npx -y @vibebrowser/mcp@latest start --transport http --host 0.0.0.0 +``` + +Clients must send the token on every `/mcp` request, including initialization: + +```http +Authorization: Bearer +``` + +The unauthenticated `/health` and `/` endpoints expose only bridge status. ### Multi-Agent Mode @@ -623,6 +639,7 @@ npx -y @vibebrowser/mcp@latest [start] [options] --host Host to bind the HTTP server to (default: 127.0.0.1) --http-port Port for streamable HTTP MCP transport (default: 8788) --http-path Path for streamable HTTP MCP transport (default: /mcp) + --http-bearer-token Bearer token required for HTTP MCP requests (or VIBE_MCP_HTTP_BEARER_TOKEN) --allow-host Allowed host header for HTTP transport (repeatable) -r, --remote Extension UUID, or full ws(s) remote URL. This routing UUID is the sole bearer credential — treat it like a password. --devtools Drive your real running Chrome directly over the DevTools Protocol (bypasses the extension relay) diff --git a/scripts/e2e-http-streamable.mjs b/scripts/e2e-http-streamable.mjs index 3c8715d..af99070 100644 --- a/scripts/e2e-http-streamable.mjs +++ b/scripts/e2e-http-streamable.mjs @@ -1,5 +1,6 @@ #!/usr/bin/env node import { spawn } from 'node:child_process'; +import http from 'node:http'; import net from 'node:net'; import { mkdtempSync, rmSync } from 'node:fs'; import process from 'node:process'; @@ -21,8 +22,9 @@ RESERVED_PORTS.add(RELAY_PORT); const RELAY_PORT_B = await findFreePort(RESERVED_PORTS); const RELAY_URL = `ws://${RELAY_HOST}:${RELAY_PORT}`; const RELAY_URL_B = `ws://${RELAY_HOST}:${RELAY_PORT_B}`; -const REMOTE_UUID = 'test-http-relay-uuid'; -const REMOTE_UUID_B = 'test-http-relay-uuid-b'; +const REMOTE_UUID = '00000000-0000-4000-8000-000000000132'; +const REMOTE_UUID_B = '00000000-0000-4000-8000-000000000133'; +const HTTP_BEARER_TOKEN = '00000000-0000-4000-8000-000000000134'; const SESSION_ID = REMOTE_UUID; const SESSION_ID_B = REMOTE_UUID_B; const MCP_URL = `http://${RELAY_HOST}:${MCP_HTTP_PORT}/mcp`; @@ -139,28 +141,19 @@ async function main() { remoteA = startFakeRemoteRelay(RELAY_PORT, REMOTE_UUID, SESSION_ID); remoteB = startFakeRemoteRelay(RELAY_PORT_B, REMOTE_UUID_B, SESSION_ID_B); - serverProcess = spawn( - process.execPath, - [ - 'dist/cli.js', - 'start', - '--transport', - 'http', - '--host', - RELAY_HOST, - '--http-port', - String(MCP_HTTP_PORT), - '--remote', - `${RELAY_URL}/${REMOTE_UUID}`, - ], - { - stdio: ['ignore', 'pipe', 'pipe'], - env: { - ...process.env, - VIBE_MCP_STATE_DIR: stateDir, - }, - }, - ); + await verifyNonLoopbackRefusal(stateDir, remoteA); + + serverProcess = spawnHttpServer(stateDir); + await Promise.all([remoteA.nextConnection(), waitForPort(MCP_HTTP_PORT)]); + const compatibilityTransport = new StreamableHTTPClientTransport(new URL(MCP_URL)); + const compatibilityClient = new Client({ name: 'vibe-mcp-http-loopback-e2e', version: '1.0.0' }); + await withTimeout(compatibilityClient.connect(compatibilityTransport), 'loopback no-token MCP connect'); + await withTimeout(compatibilityClient.close(), 'loopback no-token MCP close'); + serverProcess.kill('SIGTERM'); + await waitForProcessExit(serverProcess); + serverProcess = undefined; + + serverProcess = spawnHttpServer(stateDir, HTTP_BEARER_TOKEN, '0.0.0.0'); let stderr = ''; serverProcess.stderr.on('data', (chunk) => { @@ -168,11 +161,15 @@ async function main() { }); const [extensionWs] = await Promise.all([ - remoteA.connected, + remoteA.nextConnection(), waitForPort(MCP_HTTP_PORT), ]); - const transport = new StreamableHTTPClientTransport(new URL(MCP_URL)); + await verifyHttpAuthentication(); + + const transport = new StreamableHTTPClientTransport(new URL(MCP_URL), { + requestInit: { headers: { Authorization: `Bearer ${HTTP_BEARER_TOKEN}` } }, + }); const client = new Client({ name: 'vibe-mcp-http-e2e', version: '1.0.0' }); await withTimeout(client.connect(transport), 'MCP client connect'); @@ -305,7 +302,7 @@ async function main() { arguments: { url: `${RELAY_URL_B}/${REMOTE_UUID_B}` }, }); const [extensionWsB, setRemoteResult] = await withTimeout(Promise.all([ - remoteB.connected, + remoteB.nextConnection(), setRemoteResultPromise, ]), 'set_remote response and relay B connection'); @@ -392,21 +389,138 @@ async function main() { } } +function spawnHttpServer(stateDir, bearerToken, host = RELAY_HOST) { + const args = [ + 'dist/cli.js', 'start', '--transport', 'http', '--host', host, + '--http-port', String(MCP_HTTP_PORT), '--remote', `${RELAY_URL}/${REMOTE_UUID}`, + ]; + if (host !== RELAY_HOST) { + args.push('--allow-host', RELAY_HOST); + } + return spawn(process.execPath, args, { + stdio: ['ignore', 'pipe', 'pipe'], + env: childEnv(stateDir, bearerToken), + }); +} + +function childEnv(stateDir, bearerToken) { + const { VIBE_MCP_HTTP_BEARER_TOKEN: _ambientToken, ...env } = process.env; + return { + ...env, + VIBE_MCP_STATE_DIR: stateDir, + ...(bearerToken ? { VIBE_MCP_HTTP_BEARER_TOKEN: bearerToken } : {}), + }; +} + +async function verifyNonLoopbackRefusal(stateDir, remote) { + const port = await findFreePort(RESERVED_PORTS); + RESERVED_PORTS.add(port); + const child = spawn(process.execPath, [ + 'dist/cli.js', 'start', '--transport', 'http', '--host', '0.0.0.0', + '--http-port', String(port), '--remote', `${RELAY_URL}/${REMOTE_UUID}`, + ], { + stdio: ['ignore', 'pipe', 'pipe'], + env: childEnv(stateDir), + }); + let stderr = ''; + child.stderr.on('data', (chunk) => { stderr += chunk.toString(); }); + const code = await withTimeout(new Promise((resolve) => child.once('exit', resolve)), 'non-loopback startup refusal'); + if (code === 0 || !stderr.includes('Non-loopback HTTP bindings require')) { + throw new Error(`Expected non-loopback startup refusal, code=${code}, stderr=${stderr}`); + } + if (await probePort(port)) { + throw new Error('Non-loopback refusal must occur before HTTP listen'); + } + if (remote.connectionCount !== 0) { + throw new Error('Non-loopback refusal must occur before relay connection'); + } +} + +async function verifyHttpAuthentication() { + const initialize = { + jsonrpc: '2.0', id: 1, method: 'initialize', + params: { protocolVersion: '2024-11-05', capabilities: {}, clientInfo: { name: 'auth-probe', version: '1.0.0' } }, + }; + const request = (method, authorization) => fetch(MCP_URL, { + method, + headers: { + 'content-type': 'application/json', + accept: 'application/json, text/event-stream', + ...(authorization ? { Authorization: authorization } : {}), + }, + ...(method === 'POST' ? { body: JSON.stringify(initialize) } : {}), + }); + + for (const method of ['POST', 'GET', 'DELETE']) { + for (const authorization of [undefined, 'Bearer invalid-token']) { + const response = await request(method, authorization); + if (response.status !== 401 || !response.headers.get('www-authenticate')?.startsWith('Bearer')) { + throw new Error(`Expected ${method} bearer challenge, got ${response.status}`); + } + } + } + + for (const method of ['GET', 'DELETE']) { + const response = await request(method, `Bearer ${HTTP_BEARER_TOKEN}`); + if (response.status !== 400) { + throw new Error(`Expected authenticated ${method} to reach session routing, got ${response.status}`); + } + } + + const hostStatus = await postWithHost(initialize, 'untrusted.example'); + if (hostStatus !== 403) { + throw new Error(`Expected SDK Host validation with valid auth, got ${hostStatus}`); + } + + const healthResponse = await fetch(`http://${RELAY_HOST}:${MCP_HTTP_PORT}/health`); + if (healthResponse.status !== 200) { + throw new Error(`Expected health endpoint to remain auth-free, got ${healthResponse.status}`); + } +} + +function postWithHost(body, host) { + return new Promise((resolve, reject) => { + const req = http.request(MCP_URL, { + method: 'POST', + headers: { + Host: host, + Authorization: `Bearer ${HTTP_BEARER_TOKEN}`, + 'content-type': 'application/json', + accept: 'application/json, text/event-stream', + }, + }, (res) => { + res.resume(); + res.once('end', () => resolve(res.statusCode)); + }); + req.once('error', reject); + req.end(JSON.stringify(body)); + }); +} + function startFakeRemoteRelay(port, uuid, sessionId) { const server = new WebSocketServer({ host: RELAY_HOST, port }); - const connected = new Promise((resolve) => { - server.on('connection', (ws, req) => { + const waiters = []; + const pending = []; + let connectionCount = 0; + server.on('connection', (ws, req) => { if (req.url !== `/${uuid}`) { ws.close(); return; } + connectionCount += 1; ws.send(JSON.stringify({ type: 'extension_status', connected: true })); ws.send(JSON.stringify({ type: 'connected', sessionId })); - resolve(ws); - }); + const waiter = waiters.shift(); + if (waiter) waiter(ws); + else pending.push(ws); + }); + const nextConnection = () => new Promise((resolve) => { + const ws = pending.shift(); + if (ws) resolve(ws); + else waiters.push(resolve); }); - return { server, connected }; + return { server, nextConnection, get connectionCount() { return connectionCount; } }; } async function closeFakeRemoteRelay(remote) { diff --git a/src/cli.ts b/src/cli.ts index df83130..aa2d7e0 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -20,6 +20,7 @@ import { serve } from './ollama.js'; import { getPackageVersion } from './version.js'; const DEFAULT_REMOTE = process.env.VIBE_REMOTE_URL || process.env.VIBE_EXTENSION_UUID || process.env.VIBE_RELAY_UUID; +const DEFAULT_HTTP_BEARER_TOKEN = process.env.VIBE_MCP_HTTP_BEARER_TOKEN; program .name('vibebrowser-mcp') @@ -40,6 +41,7 @@ program .option('--host ', 'Host to bind the HTTP server to', '127.0.0.1') .option('--http-port ', 'Port for streamable HTTP MCP transport', String(DEFAULT_HTTP_PORT)) .option('--http-path ', 'Path for streamable HTTP MCP transport', DEFAULT_HTTP_PATH) + .option('--http-bearer-token ', 'Bearer token required for streamable HTTP MCP requests (or VIBE_MCP_HTTP_BEARER_TOKEN)') .option('--allow-host ', 'Allowed host header for HTTP transport (repeatable)', collectRepeatedOption, []) .action(async (options) => { const transport = parseTransportMode(options.transport); @@ -57,6 +59,7 @@ program transport, httpPort, httpPath: options.httpPath, + httpBearerToken: options.httpBearerToken ?? DEFAULT_HTTP_BEARER_TOKEN, allowedHosts: options.allowHost.length > 0 ? options.allowHost : undefined, remoteUuid: remote, sessionId: options.session, diff --git a/src/server.ts b/src/server.ts index 505c56c..040162e 100644 --- a/src/server.ts +++ b/src/server.ts @@ -4,7 +4,7 @@ * MCP server that bridges AI clients with the Vibe browser extension. */ -import { randomUUID } from 'node:crypto'; +import { createHash, randomUUID, timingSafeEqual } from 'node:crypto'; import http, { type IncomingMessage, type ServerResponse } from 'node:http'; import { Server } from '@modelcontextprotocol/sdk/server/index.js'; import { createMcpExpressApp } from '@modelcontextprotocol/sdk/server/express.js'; @@ -94,6 +94,7 @@ export class VibeMcpServer { transport: config.transport ?? 'stdio', httpPort: config.httpPort ?? DEFAULT_HTTP_PORT, httpPath: normalizeHttpPath(config.httpPath ?? DEFAULT_HTTP_PATH), + httpBearerToken: config.httpBearerToken, allowedHosts: config.allowedHosts, remoteUuid: config.remoteUuid, sessionId: config.sessionId, @@ -121,6 +122,8 @@ export class VibeMcpServer { * Start the MCP server */ async start(): Promise { + this.validateHttpSecurity(); + try { await this.connection.start(); } catch (error) { @@ -513,12 +516,15 @@ export class VibeMcpServer { this.httpApp.get('/', healthHandler); this.httpApp.post(this.config.httpPath, async (req: HttpRequest, res: ServerResponse) => { + if (!this.authenticateHttpRequest(req, res)) return; await this.handleHttpRequest(req, res, req.body); }); this.httpApp.get(this.config.httpPath, async (req: HttpRequest, res: ServerResponse) => { + if (!this.authenticateHttpRequest(req, res)) return; await this.handleHttpRequest(req, res); }); this.httpApp.delete(this.config.httpPath, async (req: HttpRequest, res: ServerResponse) => { + if (!this.authenticateHttpRequest(req, res)) return; await this.handleHttpRequest(req, res); }); @@ -530,6 +536,45 @@ export class VibeMcpServer { this.log(`MCP server started on ${this.getHttpUrl()}`); } + private validateHttpSecurity(): void { + if (this.config.transport !== 'http') { + return; + } + + const token = this.config.httpBearerToken; + if (token !== undefined && token.trim().length === 0) { + throw new Error('HTTP bearer token must not be empty'); + } + if (!isLoopbackHost(this.config.host) && token === undefined) { + throw new Error('Non-loopback HTTP bindings require --http-bearer-token or VIBE_MCP_HTTP_BEARER_TOKEN'); + } + } + + private authenticateHttpRequest(req: IncomingMessage, res: ServerResponse): boolean { + const expected = this.config.httpBearerToken; + if (expected === undefined) { + return true; + } + + const authorization = req.headers.authorization; + const supplied = typeof authorization === 'string' + ? /^Bearer (.+)$/.exec(authorization)?.[1] + : undefined; + if (supplied && tokensEqual(supplied, expected)) { + return true; + } + + res.statusCode = 401; + res.setHeader('www-authenticate', 'Bearer realm="vibebrowser-mcp"'); + res.setHeader('content-type', 'application/json'); + res.end(JSON.stringify({ + jsonrpc: '2.0', + error: { code: -32001, message: 'Unauthorized' }, + id: null, + })); + return false; + } + /** * Handle a streamable HTTP request. */ @@ -854,6 +899,23 @@ function normalizeHttpPath(path: string): string { return path.startsWith('/') ? path : `/${path}`; } +function isLoopbackHost(host: string): boolean { + const normalized = host.trim().toLowerCase().replace(/^\[|\]$/g, ''); + if (normalized === 'localhost' || normalized === '::1') { + return true; + } + const match = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(normalized); + return match !== null + && match.slice(1).every((part) => Number(part) <= 255) + && Number(match[1]) === 127; +} + +function tokensEqual(supplied: string, expected: string): boolean { + const suppliedDigest = createHash('sha256').update(supplied, 'utf8').digest(); + const expectedDigest = createHash('sha256').update(expected, 'utf8').digest(); + return timingSafeEqual(suppliedDigest, expectedDigest); +} + function getSessionId(req: IncomingMessage): string | null { const value = req.headers['mcp-session-id']; if (Array.isArray(value)) { diff --git a/src/types.ts b/src/types.ts index 04a2c9a..686be43 100644 --- a/src/types.ts +++ b/src/types.ts @@ -125,6 +125,8 @@ export interface ServerConfig { transport: ServerTransportMode; httpPort: number; httpPath: string; + /** Bearer token required for streamable HTTP MCP requests when configured. */ + httpBearerToken?: string; allowedHosts?: string[]; /** Remote relay UUID — when set, connects to public relay instead of local */ remoteUuid?: string; From 291bc7c10f272ad9ada2fbc7de844161e399a03d Mon Sep 17 00:00:00 2001 From: Den <2119348+dzianisv@users.noreply.github.com> Date: Tue, 11 Aug 2026 22:24:01 +0000 Subject: [PATCH 2/2] fix: harden HTTP auth boundaries (#132) --- README.md | 24 +- scripts/e2e-http-streamable.mjs | 401 +++++++++++++++++++++++++++++--- src/child-env.ts | 5 + src/cli.ts | 42 +++- src/connection.ts | 3 +- src/relay.ts | 3 +- src/server.ts | 102 ++++++-- src/types.ts | 2 + 8 files changed, 526 insertions(+), 56 deletions(-) create mode 100644 src/child-env.ts diff --git a/README.md b/README.md index 1c7479d..ea84c88 100644 --- a/README.md +++ b/README.md @@ -423,17 +423,22 @@ npx -y @vibebrowser/mcp@latest start --transport http ``` Defaults: `--host 127.0.0.1`, `--http-port 8788`, `--http-path /mcp`. Add -`--allow-host ` (repeatable) if you front it with a proxy or bind it -beyond localhost. Loopback bindings are auth-free by default. A non-loopback -binding refuses to start unless `--http-bearer-token` or -`VIBE_MCP_HTTP_BEARER_TOKEN` supplies a non-empty token. Prefer the environment -variable so the token does not appear in process arguments: +`--allow-host ` (repeatable) for the hostname a reverse proxy forwards. +Only exact `127.0.0.1`, `localhost`, and `::1` bindings are auth-free by +default. The production pattern is to keep this server on loopback and expose +it through an HTTPS reverse proxy. Set a non-empty bearer token in the +environment so it does not appear in process arguments: ```bash export VIBE_MCP_HTTP_BEARER_TOKEN="$(openssl rand -hex 32)" -npx -y @vibebrowser/mcp@latest start --transport http --host 0.0.0.0 +npx -y @vibebrowser/mcp@latest start --transport http \ + --host 127.0.0.1 --allow-host browser-bridge.example.com ``` +Direct plaintext non-loopback binding is a development-only escape hatch. It +requires all three protections: a bearer token, at least one `--allow-host`, +and explicit `--allow-insecure-http` acknowledgement. + Clients must send the token on every `/mcp` request, including initialization: ```http @@ -472,10 +477,16 @@ When OpenClaw runs on a different machine (for example cloud-hosted), provide a VIBE_REMOTE_UUID="YOUR-EXTENSION-UUID" VIBE_REMOTE_URL="wss://relay.api.vibebrowser.app/YOUR-EXTENSION-UUID" PUBLIC_MCP_URL="https://browser-bridge.example.com/mcp" +export VIBE_MCP_HTTP_BEARER_TOKEN="$(openssl rand -hex 32)" npx -y @vibebrowser/mcp@latest openclaw --remote "$VIBE_REMOTE_UUID" --public-url "$PUBLIC_MCP_URL" npx -y @vibebrowser/mcp@latest openclaw --remote "$VIBE_REMOTE_URL" --public-url "$PUBLIC_MCP_URL" ``` +`--public-url` accepts HTTPS URLs only and requires +`VIBE_MCP_HTTP_BEARER_TOKEN`. Give the OpenClaw process the same environment +variable; the generated configuration references it without printing or +embedding its value. + You can print the exact OpenClaw-friendly setup with: ```bash @@ -640,6 +651,7 @@ npx -y @vibebrowser/mcp@latest [start] [options] --http-port Port for streamable HTTP MCP transport (default: 8788) --http-path Path for streamable HTTP MCP transport (default: /mcp) --http-bearer-token Bearer token required for HTTP MCP requests (or VIBE_MCP_HTTP_BEARER_TOKEN) + --allow-insecure-http Dev only: permit plaintext HTTP on a non-loopback bind when token and allowed hosts are configured --allow-host Allowed host header for HTTP transport (repeatable) -r, --remote Extension UUID, or full ws(s) remote URL. This routing UUID is the sole bearer credential — treat it like a password. --devtools Drive your real running Chrome directly over the DevTools Protocol (bypasses the extension relay) diff --git a/scripts/e2e-http-streamable.mjs b/scripts/e2e-http-streamable.mjs index af99070..e7e16d9 100644 --- a/scripts/e2e-http-streamable.mjs +++ b/scripts/e2e-http-streamable.mjs @@ -10,6 +10,7 @@ import { setTimeout as delay } from 'node:timers/promises'; import { WebSocketServer } from 'ws'; import { Client } from '@modelcontextprotocol/sdk/client/index.js'; import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'; +import { relayChildEnv } from '../dist/child-env.js'; const RELAY_HOST = '127.0.0.1'; const configuredHttpPort = getConfiguredPort('VIBE_MCP_TEST_HTTP_PORT', 'E2E_HTTP_PORT'); @@ -22,12 +23,17 @@ RESERVED_PORTS.add(RELAY_PORT); const RELAY_PORT_B = await findFreePort(RESERVED_PORTS); const RELAY_URL = `ws://${RELAY_HOST}:${RELAY_PORT}`; const RELAY_URL_B = `ws://${RELAY_HOST}:${RELAY_PORT_B}`; -const REMOTE_UUID = '00000000-0000-4000-8000-000000000132'; -const REMOTE_UUID_B = '00000000-0000-4000-8000-000000000133'; -const HTTP_BEARER_TOKEN = '00000000-0000-4000-8000-000000000134'; +const REMOTE_UUID = 'test-routing-id-a'; +const REMOTE_UUID_B = 'test-routing-id-b'; +const HTTP_BEARER_TOKEN = 'fake-http-e2e-secret'; const SESSION_ID = REMOTE_UUID; const SESSION_ID_B = REMOTE_UUID_B; const MCP_URL = `http://${RELAY_HOST}:${MCP_HTTP_PORT}/mcp`; +const PUBLIC_HOST = 'bridge.example.test'; +const INITIALIZE_REQUEST = { + jsonrpc: '2.0', id: 1, method: 'initialize', + params: { protocolVersion: '2024-11-05', capabilities: {}, clientInfo: { name: 'auth-probe', version: '1.0.0' } }, +}; function getConfiguredPort(...names) { for (const name of names) { @@ -87,6 +93,28 @@ async function waitForPort(port, timeoutMs = 15_000) { throw new Error(`Timed out waiting for port ${port}`); } +async function waitForHostPort(host, port, timeoutMs = 15_000) { + const start = Date.now(); + while (Date.now() - start < timeoutMs) { + if (await probeHostPort(host, port)) { + return; + } + await delay(100); + } + throw new Error(`Timed out waiting for ${host}:${port}`); +} + +function probeHostPort(host, port) { + return new Promise((resolve) => { + const socket = net.connect({ host, port }); + socket.on('connect', () => { + socket.destroy(); + resolve(true); + }); + socket.on('error', () => resolve(false)); + }); +} + function waitForWebSocketMessage(ws, predicate, timeoutMs = 10_000) { return new Promise((resolve, reject) => { const timer = setTimeout(() => { @@ -141,7 +169,11 @@ async function main() { remoteA = startFakeRemoteRelay(RELAY_PORT, REMOTE_UUID, SESSION_ID); remoteB = startFakeRemoteRelay(RELAY_PORT_B, REMOTE_UUID_B, SESSION_ID_B); - await verifyNonLoopbackRefusal(stateDir, remoteA); + verifyRelayChildEnv(); + await verifyOpenClawOutput(stateDir); + await verifyUnsafeBindRefusals(stateDir, remoteA); + await verifyAlternateLoopbackHostFiltering(stateDir, remoteA); + await verifyCustomHttpPathRouting(stateDir, remoteA); serverProcess = spawnHttpServer(stateDir); await Promise.all([remoteA.nextConnection(), waitForPort(MCP_HTTP_PORT)]); @@ -153,7 +185,7 @@ async function main() { await waitForProcessExit(serverProcess); serverProcess = undefined; - serverProcess = spawnHttpServer(stateDir, HTTP_BEARER_TOKEN, '0.0.0.0'); + serverProcess = spawnHttpServer(stateDir, HTTP_BEARER_TOKEN, RELAY_HOST, [RELAY_HOST, PUBLIC_HOST]); let stderr = ''; serverProcess.stderr.on('data', (chunk) => { @@ -169,6 +201,10 @@ async function main() { const transport = new StreamableHTTPClientTransport(new URL(MCP_URL), { requestInit: { headers: { Authorization: `Bearer ${HTTP_BEARER_TOKEN}` } }, + fetch: (url, init) => fetch(url, { + ...init, + headers: { ...Object.fromEntries(new Headers(init?.headers)), Host: PUBLIC_HOST }, + }), }); const client = new Client({ name: 'vibe-mcp-http-e2e', version: '1.0.0' }); @@ -376,6 +412,7 @@ async function main() { throw new Error(`Unexpected relay B tool result: ${JSON.stringify(callResultB)}`); } + await withTimeout(transport.terminateSession(), 'authenticated MCP session termination'); await withTimeout(client.close(), 'MCP client close'); console.log('http e2e ok'); } finally { @@ -389,13 +426,13 @@ async function main() { } } -function spawnHttpServer(stateDir, bearerToken, host = RELAY_HOST) { +function spawnHttpServer(stateDir, bearerToken, host = RELAY_HOST, allowedHosts = []) { const args = [ 'dist/cli.js', 'start', '--transport', 'http', '--host', host, '--http-port', String(MCP_HTTP_PORT), '--remote', `${RELAY_URL}/${REMOTE_UUID}`, ]; - if (host !== RELAY_HOST) { - args.push('--allow-host', RELAY_HOST); + for (const allowedHost of allowedHosts) { + args.push('--allow-host', allowedHost); } return spawn(process.execPath, args, { stdio: ['ignore', 'pipe', 'pipe'], @@ -404,7 +441,11 @@ function spawnHttpServer(stateDir, bearerToken, host = RELAY_HOST) { } function childEnv(stateDir, bearerToken) { - const { VIBE_MCP_HTTP_BEARER_TOKEN: _ambientToken, ...env } = process.env; + const { + VIBE_MCP_HTTP_BEARER_TOKEN: _ambientToken, + VIBE_MCP_ALLOW_INSECURE_HTTP: _ambientInsecureHttp, + ...env + } = process.env; return { ...env, VIBE_MCP_STATE_DIR: stateDir, @@ -412,21 +453,227 @@ function childEnv(stateDir, bearerToken) { }; } -async function verifyNonLoopbackRefusal(stateDir, remote) { +async function verifyOpenClawOutput(stateDir) { + const whitespaceToken = 'fake token must stay secret'; + const invalidToken = await runCli([ + 'openclaw', '--remote', REMOTE_UUID, + ], childEnv(stateDir, whitespaceToken)); + if (invalidToken.code === 0 + || !invalidToken.stderr.includes('single non-whitespace credential') + || invalidToken.stdout.length > 0 + || invalidToken.stderr.includes(whitespaceToken)) { + throw new Error('Expected OpenClaw whitespace token refusal before safe output'); + } + + const withoutToken = await runCli([ + 'openclaw', '--remote', REMOTE_UUID, '--public-url', 'https://bridge.example.test/mcp', + ], childEnv(stateDir)); + if (withoutToken.code === 0 || !withoutToken.stderr.includes('--public-url requires')) { + throw new Error(`Expected public URL without token refusal, code=${withoutToken.code}`); + } + + const insecureUrl = await runCli([ + 'openclaw', '--remote', REMOTE_UUID, '--public-url', 'http://bridge.example.test/mcp', + ], childEnv(stateDir, HTTP_BEARER_TOKEN)); + if (insecureUrl.code === 0 || !insecureUrl.stderr.includes('--public-url must use https://')) { + throw new Error(`Expected insecure public URL refusal, code=${insecureUrl.code}`); + } + + const unsafeBind = await runCli([ + 'openclaw', '--remote', REMOTE_UUID, '--host', '0.0.0.0', + '--public-url', 'https://bridge.example.test/mcp', + ], childEnv(stateDir, HTTP_BEARER_TOKEN)); + if (unsafeBind.code === 0 || !unsafeBind.stderr.includes('--public-url requires --host to be')) { + throw new Error(`Expected unsafe public bridge bind refusal, code=${unsafeBind.code}`); + } + + const localWithToken = await runCli([ + 'openclaw', '--remote', REMOTE_UUID, + ], childEnv(stateDir, HTTP_BEARER_TOKEN)); + if (localWithToken.code !== 0 + || !localWithToken.stdout.includes('Bearer ${VIBE_MCP_HTTP_BEARER_TOKEN}') + || localWithToken.stdout.includes(HTTP_BEARER_TOKEN) + || localWithToken.stderr.includes(HTTP_BEARER_TOKEN)) { + throw new Error('Expected local OpenClaw config to reference, but never expose, the environment token'); + } + + const success = await runCli([ + 'openclaw', '--remote', REMOTE_UUID, '--public-url', 'https://bridge.example.test/mcp', + ], childEnv(stateDir, HTTP_BEARER_TOKEN)); + if (success.code !== 0) { + throw new Error(`Expected OpenClaw helper success, code=${success.code}, stderr=${success.stderr}`); + } + if (!success.stdout.includes('"transport": "streamable-http"') + || !success.stdout.includes('Bearer ${VIBE_MCP_HTTP_BEARER_TOKEN}')) { + throw new Error('Expected safe OpenClaw transport and Authorization template'); + } + if (success.stdout.includes(HTTP_BEARER_TOKEN) || success.stderr.includes(HTTP_BEARER_TOKEN)) { + throw new Error('OpenClaw helper output exposed the configured bearer token'); + } + const startCommand = success.stdout.split('\n').find((line) => line.startsWith('npx ')) ?? ''; + if (startCommand.includes('bearer-token') || startCommand.includes('VIBE_MCP_HTTP_BEARER_TOKEN')) { + throw new Error('OpenClaw bridge start command must inherit auth from the environment'); + } + if (!startCommand.includes(`--allow-host ${PUBLIC_HOST}`)) { + throw new Error('OpenClaw bridge start command must allow its public proxy Host'); + } +} + +async function verifyUnsafeBindRefusals(stateDir, remote) { + await verifyStartupRefusal(stateDir, remote, ['--host', '127.0.0.2'], 'Non-loopback HTTP bindings require'); + await verifyStartupRefusal( + stateDir, + remote, + ['--host', RELAY_HOST, '--allow-host', PUBLIC_HOST], + 'Proxy-exposed HTTP endpoints require', + ); + await verifyStartupRefusal( + stateDir, + remote, + ['--host', RELAY_HOST, '--allow-host', 'attacker@localhost'], + 'Invalid --allow-host authority', + ); + await verifyStartupRefusal( + stateDir, + remote, + ['--host', RELAY_HOST, '--allow-host', 'localhost:8788'], + 'Invalid --allow-host authority', + ); + await verifyStartupRefusal( + stateDir, + remote, + ['--host', RELAY_HOST, '--http-bearer-token', 'invalid token'], + 'single non-whitespace credential', + ); + await verifyStartupRefusal( + stateDir, + remote, + ['--host', '0.0.0.0', '--http-bearer-token', HTTP_BEARER_TOKEN], + 'require --allow-insecure-http', + ); + await verifyStartupRefusal( + stateDir, + remote, + ['--host', '0.0.0.0', '--http-bearer-token', HTTP_BEARER_TOKEN, '--allow-insecure-http'], + 'require at least one --allow-host', + ); +} + +function verifyRelayChildEnv() { + const source = { + VIBE_MCP_HTTP_BEARER_TOKEN: 'fake-detached-relay-token', + VIBE_MCP_E2E_MARKER: 'preserved', + }; + const child = relayChildEnv(source); + if (source.VIBE_MCP_HTTP_BEARER_TOKEN === undefined + || child.VIBE_MCP_HTTP_BEARER_TOKEN !== undefined + || child.VIBE_MCP_E2E_MARKER !== 'preserved') { + throw new Error('Detached relay child environment did not scrub only the HTTP bearer token'); + } +} + +async function verifyCustomHttpPathRouting(stateDir, remote) { + const port = await findFreePort(RESERVED_PORTS); + RESERVED_PORTS.add(port); + const customPath = '/custom-mcp'; + const child = spawn(process.execPath, [ + 'dist/cli.js', 'start', '--transport', 'http', '--host', RELAY_HOST, + '--http-port', String(port), '--http-path', customPath, + '--remote', `${RELAY_URL}/${REMOTE_UUID}`, '--http-bearer-token', HTTP_BEARER_TOKEN, + ], { + stdio: ['ignore', 'pipe', 'pipe'], + env: childEnv(stateDir), + }); + let stderr = ''; + child.stderr.on('data', (chunk) => { stderr += chunk.toString(); }); + + try { + await Promise.all([ + remote.nextConnection(), + Promise.race([ + waitForPort(port), + new Promise((_, reject) => child.once('exit', (code) => { + reject(new Error(`Custom-path server exited with ${code}: ${redact(stderr)}`)); + })), + ]), + ]); + const exact = await rawMcpRequest('{', RELAY_HOST, undefined, RELAY_HOST, port, `${customPath}?query`); + if (exact !== 401) { + throw new Error(`Expected custom MCP path query to be preflighted with 401, got ${exact}`); + } + for (const path of ['/mcp', '/CUSTOM-MCP', '/custom-mcp/', '/custom-mcp/.']) { + const malformedStatus = await rawMcpRequest('{', RELAY_HOST, undefined, RELAY_HOST, port, path); + if (malformedStatus !== 400 && malformedStatus !== 404) { + throw new Error(`Expected malformed non-exact custom MCP path ${path} to be rejected before MCP, got ${malformedStatus}`); + } + const validStatus = await rawMcpRequest(JSON.stringify(INITIALIZE_REQUEST), RELAY_HOST, undefined, RELAY_HOST, port, path); + if (validStatus !== 400 && validStatus !== 404) { + throw new Error(`Expected non-exact custom MCP path ${path} not to reach MCP, got ${validStatus}`); + } + } + } finally { + child.kill('SIGTERM'); + await waitForProcessExit(child); + } +} + +async function verifyAlternateLoopbackHostFiltering(stateDir, remote) { + const bindHost = '127.0.0.2'; const port = await findFreePort(RESERVED_PORTS); RESERVED_PORTS.add(port); const child = spawn(process.execPath, [ - 'dist/cli.js', 'start', '--transport', 'http', '--host', '0.0.0.0', + 'dist/cli.js', 'start', '--transport', 'http', '--host', bindHost, '--http-port', String(port), '--remote', `${RELAY_URL}/${REMOTE_UUID}`, + '--http-bearer-token', HTTP_BEARER_TOKEN, '--allow-insecure-http', + '--allow-host', bindHost, + ], { + stdio: ['ignore', 'pipe', 'pipe'], + env: childEnv(stateDir), + }); + let stderr = ''; + child.stderr.on('data', (chunk) => { stderr += chunk.toString(); }); + + try { + await Promise.all([ + remote.nextConnection(), + Promise.race([ + waitForHostPort(bindHost, port), + new Promise((_, reject) => child.once('exit', (code) => { + reject(new Error(`127.0.0.2 server exited with ${code}: ${redact(stderr)}`)); + })), + ]), + ]); + const status = await rawMcpRequest( + '{', + 'hostile.example', + `Bearer ${HTTP_BEARER_TOKEN}`, + bindHost, + port, + ); + if (status !== 403) { + throw new Error(`Expected hostile Host on 127.0.0.2 to fail pre-parser with 403, got ${status}`); + } + } finally { + child.kill('SIGTERM'); + await waitForProcessExit(child); + } +} + +async function verifyStartupRefusal(stateDir, remote, extraArgs, expectedMessage) { + const port = await findFreePort(RESERVED_PORTS); + RESERVED_PORTS.add(port); + const child = spawn(process.execPath, [ + 'dist/cli.js', 'start', '--transport', 'http', '--http-port', String(port), + '--remote', `${RELAY_URL}/${REMOTE_UUID}`, ...extraArgs, ], { stdio: ['ignore', 'pipe', 'pipe'], env: childEnv(stateDir), }); let stderr = ''; child.stderr.on('data', (chunk) => { stderr += chunk.toString(); }); - const code = await withTimeout(new Promise((resolve) => child.once('exit', resolve)), 'non-loopback startup refusal'); - if (code === 0 || !stderr.includes('Non-loopback HTTP bindings require')) { - throw new Error(`Expected non-loopback startup refusal, code=${code}, stderr=${stderr}`); + const code = await withTimeout(new Promise((resolve) => child.once('exit', resolve)), 'unsafe bind startup refusal'); + if (code === 0 || !stderr.includes(expectedMessage)) { + throw new Error(`Expected startup refusal containing ${expectedMessage}, code=${code}, stderr=${redact(stderr)}`); } if (await probePort(port)) { throw new Error('Non-loopback refusal must occur before HTTP listen'); @@ -437,11 +684,8 @@ async function verifyNonLoopbackRefusal(stateDir, remote) { } async function verifyHttpAuthentication() { - const initialize = { - jsonrpc: '2.0', id: 1, method: 'initialize', - params: { protocolVersion: '2024-11-05', capabilities: {}, clientInfo: { name: 'auth-probe', version: '1.0.0' } }, - }; - const request = (method, authorization) => fetch(MCP_URL, { + const initialize = INITIALIZE_REQUEST; + const request = (method, authorization) => fetch(`${MCP_URL}?preflight=query-safe`, { method, headers: { 'content-type': 'application/json', @@ -452,8 +696,15 @@ async function verifyHttpAuthentication() { }); for (const method of ['POST', 'GET', 'DELETE']) { - for (const authorization of [undefined, 'Bearer invalid-token']) { + for (const authorization of [ + undefined, + 'Bearer invalid-token', + `Basic ${HTTP_BEARER_TOKEN}`, + `Bearer ${HTTP_BEARER_TOKEN} trailing`, + 'Bearer', + ]) { const response = await request(method, authorization); + await response.arrayBuffer(); if (response.status !== 401 || !response.headers.get('www-authenticate')?.startsWith('Bearer')) { throw new Error(`Expected ${method} bearer challenge, got ${response.status}`); } @@ -462,14 +713,68 @@ async function verifyHttpAuthentication() { for (const method of ['GET', 'DELETE']) { const response = await request(method, `Bearer ${HTTP_BEARER_TOKEN}`); + await response.arrayBuffer(); if (response.status !== 400) { throw new Error(`Expected authenticated ${method} to reach session routing, got ${response.status}`); } } - const hostStatus = await postWithHost(initialize, 'untrusted.example'); + const malformedUnauthorized = await rawMcpRequest('{', RELAY_HOST); + if (malformedUnauthorized !== 401) { + throw new Error(`Expected malformed unauthenticated JSON to fail pre-parser with 401, got ${malformedUnauthorized}`); + } + + const queryUnauthorized = await rawMcpRequest('{', RELAY_HOST, undefined, RELAY_HOST, MCP_HTTP_PORT, '/mcp?query'); + if (queryUnauthorized !== 401) { + throw new Error(`Expected MCP query path to be preflighted with 401, got ${queryUnauthorized}`); + } + + for (const path of ['/MCP', '/mcp/', '/MCP/', '/mcp/.']) { + const malformedStatus = await rawMcpRequest('{', RELAY_HOST, undefined, RELAY_HOST, MCP_HTTP_PORT, path); + if (malformedStatus !== 400 && malformedStatus !== 404) { + throw new Error(`Expected malformed non-exact MCP path ${path} to be rejected before MCP, got ${malformedStatus}`); + } + const validStatus = await rawMcpRequest(JSON.stringify(initialize), RELAY_HOST, undefined, RELAY_HOST, MCP_HTTP_PORT, path); + if (validStatus !== 400 && validStatus !== 404) { + throw new Error(`Expected non-exact MCP path ${path} not to reach MCP, got ${validStatus}`); + } + } + + const malformedHostile = await rawMcpRequest('{', 'untrusted.example', `Bearer ${HTTP_BEARER_TOKEN}`); + if (malformedHostile !== 403) { + throw new Error(`Expected hostile Host to fail before auth/parser with 403, got ${malformedHostile}`); + } + + const deceptiveHost = await rawMcpRequest('{', `attacker@${RELAY_HOST}`, `Bearer ${HTTP_BEARER_TOKEN}`); + if (deceptiveHost !== 403) { + throw new Error(`Expected malformed Host authority to fail before auth/parser with 403, got ${deceptiveHost}`); + } + + const missingHost = await rawHttpStatus([ + 'POST /mcp HTTP/1.0', + `Authorization: Bearer ${HTTP_BEARER_TOKEN}`, + 'Content-Type: application/json', + 'Content-Length: 1', + 'Connection: close', + '', + '{', + ].join('\r\n')); + if (missingHost !== 403) { + throw new Error(`Expected missing Host to fail before auth/parser with 403, got ${missingHost}`); + } + + const mixedCaseBearer = await rawMcpRequest( + JSON.stringify(initialize), + RELAY_HOST, + ` \t bEaReR \t ${HTTP_BEARER_TOKEN} `, + ); + if (mixedCaseBearer !== 200) { + throw new Error(`Expected mixed-case Bearer with whitespace to authenticate, got ${mixedCaseBearer}`); + } + + const hostStatus = await rawMcpRequest(JSON.stringify(initialize), 'untrusted.example', `Bearer ${HTTP_BEARER_TOKEN}`); if (hostStatus !== 403) { - throw new Error(`Expected SDK Host validation with valid auth, got ${hostStatus}`); + throw new Error(`Expected Host validation with valid auth, got ${hostStatus}`); } const healthResponse = await fetch(`http://${RELAY_HOST}:${MCP_HTTP_PORT}/health`); @@ -478,13 +783,17 @@ async function verifyHttpAuthentication() { } } -function postWithHost(body, host) { +function rawMcpRequest(body, host, authorization, connectHost = RELAY_HOST, port = MCP_HTTP_PORT, path = '/mcp') { return new Promise((resolve, reject) => { - const req = http.request(MCP_URL, { + const req = http.request({ + hostname: connectHost, + port, + path, method: 'POST', + setHost: host !== undefined, headers: { - Host: host, - Authorization: `Bearer ${HTTP_BEARER_TOKEN}`, + ...(host !== undefined ? { Host: host } : {}), + ...(authorization ? { Authorization: authorization } : {}), 'content-type': 'application/json', accept: 'application/json, text/event-stream', }, @@ -493,10 +802,48 @@ function postWithHost(body, host) { res.once('end', () => resolve(res.statusCode)); }); req.once('error', reject); - req.end(JSON.stringify(body)); + req.end(body); }); } +function rawHttpStatus(request) { + return new Promise((resolve, reject) => { + const socket = net.connect({ host: RELAY_HOST, port: MCP_HTTP_PORT }); + let response = ''; + socket.setEncoding('utf8'); + socket.once('connect', () => socket.end(request)); + socket.on('data', (chunk) => { response += chunk; }); + socket.once('error', reject); + socket.once('close', () => { + const match = /^HTTP\/1\.[01] (\d{3})/.exec(response); + if (!match) { + reject(new Error(`Invalid raw HTTP response: ${response}`)); + return; + } + resolve(Number.parseInt(match[1], 10)); + }); + }); +} + +function runCli(args, env) { + return new Promise((resolve, reject) => { + const child = spawn(process.execPath, ['dist/cli.js', ...args], { + stdio: ['ignore', 'pipe', 'pipe'], + env, + }); + let stdout = ''; + let stderr = ''; + child.stdout.on('data', (chunk) => { stdout += chunk.toString(); }); + child.stderr.on('data', (chunk) => { stderr += chunk.toString(); }); + child.once('error', reject); + child.once('exit', (code) => resolve({ code, stdout, stderr })); + }); +} + +function redact(value) { + return value.split(HTTP_BEARER_TOKEN).join('[redacted]'); +} + function startFakeRemoteRelay(port, uuid, sessionId) { const server = new WebSocketServer({ host: RELAY_HOST, port }); const waiters = []; diff --git a/src/child-env.ts b/src/child-env.ts new file mode 100644 index 0000000..5266706 --- /dev/null +++ b/src/child-env.ts @@ -0,0 +1,5 @@ +export function relayChildEnv(source: NodeJS.ProcessEnv): NodeJS.ProcessEnv { + const env = { ...source }; + delete env.VIBE_MCP_HTTP_BEARER_TOKEN; + return env; +} diff --git a/src/cli.ts b/src/cli.ts index aa2d7e0..b878bd4 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -42,6 +42,7 @@ program .option('--http-port ', 'Port for streamable HTTP MCP transport', String(DEFAULT_HTTP_PORT)) .option('--http-path ', 'Path for streamable HTTP MCP transport', DEFAULT_HTTP_PATH) .option('--http-bearer-token ', 'Bearer token required for streamable HTTP MCP requests (or VIBE_MCP_HTTP_BEARER_TOKEN)') + .option('--allow-insecure-http', 'Dev only: allow plaintext HTTP on a non-loopback bind when token and allowed hosts are configured', false) .option('--allow-host ', 'Allowed host header for HTTP transport (repeatable)', collectRepeatedOption, []) .action(async (options) => { const transport = parseTransportMode(options.transport); @@ -60,6 +61,7 @@ program httpPort, httpPath: options.httpPath, httpBearerToken: options.httpBearerToken ?? DEFAULT_HTTP_BEARER_TOKEN, + allowInsecureHttp: options.allowInsecureHttp, allowedHosts: options.allowHost.length > 0 ? options.allowHost : undefined, remoteUuid: remote, sessionId: options.session, @@ -90,6 +92,16 @@ program const httpPort = parsePort(options.httpPort, 'HTTP port'); const httpPath = normalizePath(options.httpPath); const host = options.host; + const token = DEFAULT_HTTP_BEARER_TOKEN; + if (token !== undefined && !/^\S+$/.test(token)) { + throw new Error('HTTP bearer token must be a single non-whitespace credential'); + } + if (options.publicUrl && (!token || token.trim().length === 0)) { + throw new Error('--public-url requires a non-empty VIBE_MCP_HTTP_BEARER_TOKEN'); + } + if (options.publicUrl && !isSafeHttpBind(host)) { + throw new Error('--public-url requires --host to be 127.0.0.1, localhost, or ::1 for a loopback TLS proxy'); + } const localHttpUrl = `http://${formatHost(host)}:${httpPort}${httpPath}`; const openClawUrl = options.publicUrl ? normalizePublicUrl(String(options.publicUrl), httpPath) @@ -111,15 +123,26 @@ program options.remote, ]; - for (const allowedHost of options.allowHost as string[]) { + const allowedHosts = new Set(options.allowHost as string[]); + if (options.publicUrl) { + allowedHosts.add(new URL(openClawUrl).hostname); + } + for (const allowedHost of allowedHosts) { cliArgs.push('--allow-host', allowedHost); } + const vibeConfig: Record = { + url: openClawUrl, + transport: 'streamable-http', + }; + if (token && token.trim().length > 0) { + vibeConfig.headers = { + Authorization: 'Bearer ${VIBE_MCP_HTTP_BEARER_TOKEN}', + }; + } const openClawConfig = { mcpServers: { - vibe: { - url: openClawUrl, - }, + vibe: vibeConfig, }, }; @@ -139,6 +162,9 @@ program console.log(''); console.log('OpenClaw JSON snippet:'); console.log(JSON.stringify(openClawConfig, null, 2)); + if (token && token.trim().length > 0) { + console.log('Ensure the OpenClaw process also has VIBE_MCP_HTTP_BEARER_TOKEN in its environment.'); + } } catch (error) { const message = error instanceof Error ? error.message : String(error); console.error(`Error: ${message}`); @@ -201,8 +227,16 @@ function formatHost(host: string): string { return host.includes(':') && !host.startsWith('[') ? `[${host}]` : host; } +function isSafeHttpBind(host: string): boolean { + const normalized = host.trim().toLowerCase().replace(/^\[|\]$/g, ''); + return normalized === '127.0.0.1' || normalized === 'localhost' || normalized === '::1'; +} + function normalizePublicUrl(value: string, fallbackPath: string): string { const parsed = parseHttpUrl(value, '--public-url'); + if (parsed.protocol !== 'https:') { + throw new Error('--public-url must use https://'); + } if (!parsed.pathname || parsed.pathname === '/') { parsed.pathname = fallbackPath; } diff --git a/src/connection.ts b/src/connection.ts index 0be3426..935f760 100644 --- a/src/connection.ts +++ b/src/connection.ts @@ -20,6 +20,7 @@ import { ToolResult, } from './types.js'; import { isRelayRunning, AGENT_PORT, EXTENSION_PORT } from './relay.js'; +import { relayChildEnv } from './child-env.js'; const NO_CONNECTION_MESSAGE = `No connection to Vibe extension. Please: 1. Install the Vibe AI Browser extension from https://vibebrowser.app @@ -231,7 +232,7 @@ export class ExtensionConnection extends EventEmitter { const child = spawn(process.execPath, [relayScript, this.debug ? '--debug' : ''], { detached: true, stdio: 'ignore', - env: process.env, + env: relayChildEnv(process.env), }); child.unref(); diff --git a/src/relay.ts b/src/relay.ts index 4c6ddde..a9704b9 100644 --- a/src/relay.ts +++ b/src/relay.ts @@ -16,6 +16,7 @@ import { homedir } from 'os'; import { EventEmitter } from 'events'; import { DevtoolsFallbackConnection } from './devtools-fallback.js'; import type { ToolDefinition } from './types.js'; +import { relayChildEnv } from './child-env.js'; function parseEnvPort(name: string, fallback: number): number { const raw = process.env[name]; @@ -1088,7 +1089,7 @@ export function spawnRelayDaemon(debug: boolean = false): void { detached: true, stdio: 'ignore', cwd: VIBE_DIR, - env: process.env, + env: relayChildEnv(process.env), }); child.unref(); diff --git a/src/server.ts b/src/server.ts index 040162e..529d854 100644 --- a/src/server.ts +++ b/src/server.ts @@ -95,6 +95,7 @@ export class VibeMcpServer { httpPort: config.httpPort ?? DEFAULT_HTTP_PORT, httpPath: normalizeHttpPath(config.httpPath ?? DEFAULT_HTTP_PATH), httpBearerToken: config.httpBearerToken, + allowInsecureHttp: config.allowInsecureHttp ?? false, allowedHosts: config.allowedHosts, remoteUuid: config.remoteUuid, sessionId: config.sessionId, @@ -496,6 +497,10 @@ export class VibeMcpServer { host: this.config.host, allowedHosts: this.config.allowedHosts, }); + this.httpApp.set('case sensitive routing', true); + this.httpApp.set('strict routing', true); + this.httpApp.router.caseSensitive = true; + this.httpApp.router.strict = true; const healthHandler = (_req: IncomingMessage, res: ServerResponse) => { res.statusCode = 200; @@ -516,20 +521,23 @@ export class VibeMcpServer { this.httpApp.get('/', healthHandler); this.httpApp.post(this.config.httpPath, async (req: HttpRequest, res: ServerResponse) => { - if (!this.authenticateHttpRequest(req, res)) return; await this.handleHttpRequest(req, res, req.body); }); this.httpApp.get(this.config.httpPath, async (req: HttpRequest, res: ServerResponse) => { - if (!this.authenticateHttpRequest(req, res)) return; await this.handleHttpRequest(req, res); }); this.httpApp.delete(this.config.httpPath, async (req: HttpRequest, res: ServerResponse) => { - if (!this.authenticateHttpRequest(req, res)) return; await this.handleHttpRequest(req, res); }); this.httpServer = await new Promise((resolve, reject) => { - const server = this.httpApp!.listen(this.config.httpPort, this.config.host, () => resolve(server)); + const server = http.createServer((req, res) => { + if (requestPathname(req) === this.config.httpPath && !this.preflightHttpRequest(req, res)) { + return; + } + this.httpApp!(req, res); + }); + server.listen(this.config.httpPort, this.config.host, () => resolve(server)); server.once('error', reject); }); @@ -542,15 +550,39 @@ export class VibeMcpServer { } const token = this.config.httpBearerToken; - if (token !== undefined && token.trim().length === 0) { - throw new Error('HTTP bearer token must not be empty'); + if (token !== undefined && !/^\S+$/.test(token)) { + throw new Error('HTTP bearer token must be a single non-whitespace credential'); } - if (!isLoopbackHost(this.config.host) && token === undefined) { - throw new Error('Non-loopback HTTP bindings require --http-bearer-token or VIBE_MCP_HTTP_BEARER_TOKEN'); + + const normalizedAllowedHosts = (this.config.allowedHosts ?? []).map((allowedHost) => { + const normalized = normalizeAllowedHostname(allowedHost); + if (!normalized) { + throw new Error(`Invalid --allow-host authority: ${allowedHost}`); + } + return normalized; + }); + if (normalizedAllowedHosts.some((allowedHost) => !isSafeHttpHostname(allowedHost)) && token === undefined) { + throw new Error('Proxy-exposed HTTP endpoints require --http-bearer-token or VIBE_MCP_HTTP_BEARER_TOKEN'); + } + if (!isSafeHttpBind(this.config.host)) { + if (token === undefined) { + throw new Error('Non-loopback HTTP bindings require --http-bearer-token or VIBE_MCP_HTTP_BEARER_TOKEN'); + } + if (!this.config.allowInsecureHttp) { + throw new Error('Non-loopback plaintext HTTP bindings require --allow-insecure-http'); + } + if (!this.config.allowedHosts || this.config.allowedHosts.length === 0) { + throw new Error('Non-loopback plaintext HTTP bindings require at least one --allow-host'); + } } } - private authenticateHttpRequest(req: IncomingMessage, res: ServerResponse): boolean { + private preflightHttpRequest(req: IncomingMessage, res: ServerResponse): boolean { + if (!isAllowedHostHeader(req.headers.host, this.config.allowedHosts)) { + writeJsonRpcError(res, 403, 'Forbidden'); + return false; + } + const expected = this.config.httpBearerToken; if (expected === undefined) { return true; @@ -558,7 +590,7 @@ export class VibeMcpServer { const authorization = req.headers.authorization; const supplied = typeof authorization === 'string' - ? /^Bearer (.+)$/.exec(authorization)?.[1] + ? /^[ \t]*Bearer[ \t]+([^\s]+)[ \t]*$/i.exec(authorization)?.[1] : undefined; if (supplied && tokensEqual(supplied, expected)) { return true; @@ -899,15 +931,51 @@ function normalizeHttpPath(path: string): string { return path.startsWith('/') ? path : `/${path}`; } -function isLoopbackHost(host: string): boolean { +function isSafeHttpBind(host: string): boolean { const normalized = host.trim().toLowerCase().replace(/^\[|\]$/g, ''); - if (normalized === 'localhost' || normalized === '::1') { - return true; + return isSafeHttpHostname(normalized); +} + +function isSafeHttpHostname(hostname: string): boolean { + return hostname === '127.0.0.1' || hostname === 'localhost' || hostname === '::1'; +} + +function requestPathname(req: IncomingMessage): string | null { + try { + return new URL(req.url ?? '/', 'http://localhost').pathname; + } catch { + return null; + } +} + +function isAllowedHostHeader(hostHeader: string | undefined, configuredHosts?: string[]): boolean { + const hostname = hostHeader ? normalizeAllowedHostname(hostHeader, true) : null; + if (!hostname) { + return false; + } + + const allowedHosts = configuredHosts && configuredHosts.length > 0 + ? configuredHosts + : ['127.0.0.1', 'localhost', '[::1]']; + return allowedHosts.some((allowedHost) => normalizeAllowedHostname(allowedHost) === hostname); +} + +function normalizeAllowedHostname(value: string, allowPort = false): string | null { + if (!value || value !== value.trim()) { + return null; + } + if (value.toLowerCase() === '::1') { + return '::1'; + } + try { + const parsed = new URL(`http://${value}`); + if (parsed.username || parsed.password || (!allowPort && parsed.port) || parsed.pathname !== '/' || parsed.search || parsed.hash) { + return null; + } + return parsed.hostname.toLowerCase().replace(/^\[|\]$/g, ''); + } catch { + return null; } - const match = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(normalized); - return match !== null - && match.slice(1).every((part) => Number(part) <= 255) - && Number(match[1]) === 127; } function tokensEqual(supplied: string, expected: string): boolean { diff --git a/src/types.ts b/src/types.ts index 686be43..05638ea 100644 --- a/src/types.ts +++ b/src/types.ts @@ -127,6 +127,8 @@ export interface ServerConfig { httpPath: string; /** Bearer token required for streamable HTTP MCP requests when configured. */ httpBearerToken?: string; + /** Dev-only opt-in for binding plaintext HTTP beyond exact loopback names. */ + allowInsecureHttp?: boolean; allowedHosts?: string[]; /** Remote relay UUID — when set, connects to public relay instead of local */ remoteUuid?: string;