feat(cli): add mcp http security helpers

This commit is contained in:
Andrey Avtomonov 2026-05-14 18:47:50 +02:00
parent e974f3e59f
commit 7ffa99983f
2 changed files with 216 additions and 0 deletions

View file

@ -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,
});
});
});

View file

@ -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<string>(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<string, string | undefined>, 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<string, string | undefined> },
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 };
}