From 7ffa99983f6e6a65555c4709e64e25fdc17d5261 Mon Sep 17 00:00:00 2001 From: Andrey Avtomonov <7889985+andreybavt@users.noreply.github.com> Date: Thu, 14 May 2026 18:47:50 +0200 Subject: [PATCH] feat(cli): add mcp http security helpers --- packages/cli/src/mcp-http-server.test.ts | 116 +++++++++++++++++++++++ packages/cli/src/mcp-http-server.ts | 100 +++++++++++++++++++ 2 files changed, 216 insertions(+) create mode 100644 packages/cli/src/mcp-http-server.test.ts create mode 100644 packages/cli/src/mcp-http-server.ts diff --git a/packages/cli/src/mcp-http-server.test.ts b/packages/cli/src/mcp-http-server.test.ts new file mode 100644 index 00000000..bc50494e --- /dev/null +++ b/packages/cli/src/mcp-http-server.test.ts @@ -0,0 +1,116 @@ +import { describe, expect, it } from 'vitest'; +import { + buildMcpSecurityConfig, + isMcpRequestAuthorized, + normalizeHostHeader, +} from './mcp-http-server.js'; + +describe('normalizeHostHeader', () => { + it('normalizes host headers before allow-list comparison', () => { + expect(normalizeHostHeader('LOCALHOST:7878')).toBe('localhost'); + expect(normalizeHostHeader('127.0.0.1:7878')).toBe('127.0.0.1'); + expect(normalizeHostHeader('[::1]:7878')).toBe('::1'); + expect(normalizeHostHeader(' Example.COM ')).toBe('example.com'); + }); +}); + +describe('buildMcpSecurityConfig', () => { + it('allows loopback hosts without a token', () => { + const config = buildMcpSecurityConfig({ + host: '127.0.0.1', + port: 7878, + token: undefined, + allowedHosts: [], + allowedOrigins: [], + }); + + expect(config.token).toBeUndefined(); + expect(config.allowedHosts).toEqual(['localhost', '127.0.0.1', '::1']); + }); + + it('requires a token for non-loopback binding', () => { + expect(() => + buildMcpSecurityConfig({ + host: '0.0.0.0', + port: 7878, + token: undefined, + allowedHosts: [], + allowedOrigins: [], + }), + ).toThrow('Binding KTX MCP to 0.0.0.0 requires --token or KTX_MCP_TOKEN'); + }); + + it('validates allowed origins as full origins', () => { + expect(() => + buildMcpSecurityConfig({ + host: '127.0.0.1', + port: 7878, + token: undefined, + allowedHosts: [], + allowedOrigins: ['localhost:7878'], + }), + ).toThrow('Allowed origin must be a full origin URL'); + }); +}); + +describe('isMcpRequestAuthorized', () => { + const config = buildMcpSecurityConfig({ + host: '0.0.0.0', + port: 7878, + token: 'secret-token', + allowedHosts: ['mcp.example.test'], + allowedOrigins: ['https://mcp.example.test'], + }); + + it('accepts a valid host, origin, and bearer token', () => { + expect( + isMcpRequestAuthorized( + { + path: '/mcp', + headers: { + host: 'mcp.example.test:7878', + origin: 'https://mcp.example.test', + authorization: 'Bearer secret-token', + }, + }, + config, + ), + ).toEqual({ ok: true }); + }); + + it('rejects bad host headers before MCP handling', () => { + expect( + isMcpRequestAuthorized( + { path: '/health', headers: { host: 'evil.example.test' } }, + config, + ), + ).toEqual({ ok: false, status: 403, message: 'Host header is not allowed for KTX MCP.' }); + }); + + it('rejects browser origins unless explicitly allowed', () => { + expect( + isMcpRequestAuthorized( + { + path: '/health', + headers: { host: 'mcp.example.test', origin: 'https://evil.example.test' }, + }, + config, + ), + ).toEqual({ ok: false, status: 403, message: 'Origin header is not allowed for KTX MCP.' }); + }); + + it('requires bearer auth on /mcp when token auth is enabled', () => { + expect( + isMcpRequestAuthorized( + { path: '/mcp', headers: { host: 'mcp.example.test', authorization: 'Bearer wrong' } }, + config, + ), + ).toEqual({ ok: false, status: 401, message: 'Missing or invalid KTX MCP bearer token.' }); + }); + + it('does not require bearer auth on /health', () => { + expect(isMcpRequestAuthorized({ path: '/health', headers: { host: 'mcp.example.test' } }, config)).toEqual({ + ok: true, + }); + }); +}); diff --git a/packages/cli/src/mcp-http-server.ts b/packages/cli/src/mcp-http-server.ts new file mode 100644 index 00000000..53b0d495 --- /dev/null +++ b/packages/cli/src/mcp-http-server.ts @@ -0,0 +1,100 @@ +import type { IncomingHttpHeaders } from 'node:http'; + +const DEFAULT_ALLOWED_HOSTS = ['localhost', '127.0.0.1', '::1'] as const; + +export interface McpSecurityConfigInput { + host: string; + port: number; + token?: string; + allowedHosts: string[]; + allowedOrigins: string[]; +} + +export interface McpSecurityConfig { + host: string; + port: number; + token?: string; + allowedHosts: string[]; + allowedOrigins: string[]; +} + +export type McpAuthorizationResult = + | { ok: true } + | { ok: false; status: 401 | 403; message: string }; + +function isLoopbackHost(host: string): boolean { + const normalized = normalizeHostHeader(host); + return normalized === 'localhost' || normalized === '127.0.0.1' || normalized === '::1'; +} + +export function normalizeHostHeader(value: string): string { + const trimmed = value.trim().toLowerCase(); + if (trimmed.startsWith('[')) { + const close = trimmed.indexOf(']'); + return close >= 0 ? trimmed.slice(1, close) : trimmed.replace(/^\[/, ''); + } + const colon = trimmed.lastIndexOf(':'); + if (colon > -1 && trimmed.indexOf(':') === colon) { + return trimmed.slice(0, colon); + } + return trimmed; +} + +function fullOrigin(value: string): string { + let parsed: URL; + try { + parsed = new URL(value); + } catch { + throw new Error(`Allowed origin must be a full origin URL: ${value}`); + } + if (!parsed.protocol || !parsed.host || parsed.pathname !== '/' || parsed.search || parsed.hash) { + throw new Error(`Allowed origin must be a full origin URL: ${value}`); + } + return parsed.origin; +} + +export function buildMcpSecurityConfig(input: McpSecurityConfigInput): McpSecurityConfig { + if (!isLoopbackHost(input.host) && !input.token) { + throw new Error(`Binding KTX MCP to ${input.host} requires --token or KTX_MCP_TOKEN`); + } + const allowedHostSet = new Set(DEFAULT_ALLOWED_HOSTS); + if (!isLoopbackHost(input.host)) { + allowedHostSet.add(normalizeHostHeader(input.host)); + } + for (const host of input.allowedHosts) { + allowedHostSet.add(normalizeHostHeader(host)); + } + return { + host: input.host, + port: input.port, + ...(input.token ? { token: input.token } : {}), + allowedHosts: [...allowedHostSet], + allowedOrigins: input.allowedOrigins.map(fullOrigin), + }; +} + +function headerValue(headers: IncomingHttpHeaders | Record, name: string): string | undefined { + const value = headers[name.toLowerCase()]; + return Array.isArray(value) ? value[0] : value; +} + +export function isMcpRequestAuthorized( + request: { path: string; headers: IncomingHttpHeaders | Record }, + config: McpSecurityConfig, +): McpAuthorizationResult { + const host = headerValue(request.headers, 'host'); + if (!host || !config.allowedHosts.includes(normalizeHostHeader(host))) { + return { ok: false, status: 403, message: 'Host header is not allowed for KTX MCP.' }; + } + const origin = headerValue(request.headers, 'origin'); + if (origin && !config.allowedOrigins.includes(origin)) { + return { ok: false, status: 403, message: 'Origin header is not allowed for KTX MCP.' }; + } + if (request.path === '/mcp' && config.token) { + const auth = headerValue(request.headers, 'authorization'); + if (auth !== `Bearer ${config.token}`) { + return { ok: false, status: 401, message: 'Missing or invalid KTX MCP bearer token.' }; + } + } + return { ok: true }; +}