From 7bf914d06c4ef7680c00a8261b96ccbaf12d6883 Mon Sep 17 00:00:00 2001 From: Sebastion Date: Fri, 8 May 2026 19:48:34 +0100 Subject: [PATCH] fix(transport): require auth token for HTTP MCP server on non-loopback hosts The HTTP transport bound to a configurable hostname (incl. 0.0.0.0) and served the /mcp endpoint with no authentication. Anyone able to reach the port could open new MCP sessions and drive browser automation, indirectly using the configured Browserbase / model API keys (CWE-319). This change: - Reads MCP_AUTH_TOKEN from the environment. - Refuses to start the HTTP transport on a non-loopback host unless MCP_AUTH_TOKEN is set, exiting with a clear error message. - When MCP_AUTH_TOKEN is set, validates a 'Authorization: Bearer ...' header on every HTTP request using a constant-time comparison and returns 401 with WWW-Authenticate otherwise. - Prints a warning when running on loopback without a token. - Includes the Authorization header hint in the printed client config when a token is configured. --- src/transport.ts | 76 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 76 insertions(+) diff --git a/src/transport.ts b/src/transport.ts index 4885bf91..98f0d278 100644 --- a/src/transport.ts +++ b/src/transport.ts @@ -65,11 +65,66 @@ async function handleStreamable( res.end("Invalid request"); } +/** + * Constant-time comparison to avoid timing side-channels when validating + * a bearer token. Returns false if lengths differ or buffers don't match. + */ +function safeTokenEqual(a: string, b: string): boolean { + const aBuf = Buffer.from(a, "utf8"); + const bBuf = Buffer.from(b, "utf8"); + if (aBuf.length !== bBuf.length) return false; + return crypto.timingSafeEqual(aBuf, bBuf); +} + +/** + * Extract a bearer token from the Authorization header. + */ +function extractBearerToken(req: http.IncomingMessage): string | undefined { + const auth = req.headers["authorization"]; + if (!auth || typeof auth !== "string") return undefined; + const match = auth.match(/^Bearer\s+(.+)$/i); + return match ? match[1].trim() : undefined; +} + +function isLoopback(host: string | undefined): boolean { + if (!host) return true; // Node defaults to listening on localhost when host is undefined + return ( + host === "localhost" || + host === "127.0.0.1" || + host === "::1" || + host === "::ffff:127.0.0.1" + ); +} + export function startHttpTransport( port: number, hostname: string | undefined, serverList: ServerList, ) { + // Resolve the auth token. When the server binds to a non-loopback + // interface we REQUIRE an auth token, otherwise the MCP server (and the + // configured Browserbase / model API keys behind it) would be exposed to + // anyone able to reach the port. See CWE-319. + const authToken = + process.env.MCP_AUTH_TOKEN && process.env.MCP_AUTH_TOKEN.length > 0 + ? process.env.MCP_AUTH_TOKEN + : undefined; + + if (!isLoopback(hostname) && !authToken) { + console.error( + `Refusing to start HTTP transport on non-loopback host '${hostname}' without authentication.\n` + + `Set the MCP_AUTH_TOKEN environment variable to a strong secret, or bind to localhost (omit --host or use --host localhost).`, + ); + process.exit(1); + } + + if (!authToken) { + console.error( + "Warning: HTTP transport is starting without authentication (MCP_AUTH_TOKEN is not set). " + + "Only loopback connections will be accepted; do not expose this port to other hosts.", + ); + } + // In-memory Map of SHTTP sessions const streamableSessions = new Map(); const httpServer = http.createServer(async (req, res) => { @@ -78,6 +133,20 @@ export function startHttpTransport( res.end("Bad request: missing URL"); return; } + + // Authenticate every request to the /mcp endpoint when a token is + // configured. Use a constant-time comparison to avoid leaking the token + // through timing side-channels. + if (authToken) { + const provided = extractBearerToken(req); + if (!provided || !safeTokenEqual(provided, authToken)) { + res.statusCode = 401; + res.setHeader("WWW-Authenticate", 'Bearer realm="mcp"'); + res.end("Unauthorized"); + return; + } + } + const url = new URL(`http://localhost${req.url}`); if (url.pathname.startsWith("/mcp")) await handleStreamable(req, res, serverList, streamableSessions); @@ -105,6 +174,13 @@ export function startHttpTransport( browserbase: { type: "http", url: `${url}/mcp`, + ...(authToken + ? { + headers: { + Authorization: "Bearer ", + }, + } + : {}), }, }, },