Root cause
Other / insufficient public mechanism detail introduced
Add: MCP Streamable HTTP transport (#11)
AI-assisted change: Claude
How AI contributed: Direct introductionPublished Jun 19, 2026 in
kozou-dev/kozou
The AI-written Streamable HTTP transport for @kozou/mcp dispatched requests on the URL pathname alone and never inspected the Host or Origin header, so a page on another origin could reach a local MCP listener through DNS rebinding. The same server read request bodies without any size limit and returned err.message verbatim in its 500 responses. The fix adds Host and Origin allowlisting, a body cap of 1 MiB and generic error replies.
startHttpServer.ts routed on pathname with no Host or Origin validation, and handleMcp accumulated the request body without a cap before respondError echoed err.message back to the client. The fix checks Host and Origin on every route, limits bodies to maxBodyBytes, and makes generated unauthenticated listeners bind to loopback.
Root cause
Add: MCP Streamable HTTP transport (#11)
AI-assisted change: Claude
Fix
The patch validates Host and Origin on the MCP HTTP listener, limits MCP request bodies, returns generic server errors, and makes generated unauthenticated listener bindings loopback-only.
AI-assisted fix: Claude Code · Takashi Matsuyama
Case facts
@@ -0,0 +1,242 @@+// Convenience helper to start an MCP server with the Streamable HTTP+// transport (Kozou v0.1 spec §7.1). Mirrors startStdioServer so callers+// (the bundled kozou CLI and adopters embedding the server) do not need+// to depend on @modelcontextprotocol/sdk or node:http directly.+//+// Design notes:+// - The server is *stateful*: an MCP client initialises a session, the+// transport issues an `mcp-session-id`, and subsequent requests reuse+// the matching transport. This is the transport mode the standard MCP+// client (Claude Desktop / Claude Code) speaks. Sessions are torn+// down on transport close so nothing accumulates.+// - Cache invalidation is exposed over HTTP via `POST /admin/refresh`+// (Kozou v0.1 spec §7.5), the HTTP-mode counterpart to the stdio+// server's SIGHUP handler.+// - v0.1 MCP HTTP ships with **no authentication** (spec §18.5), so the+// server binds to localhost by default and prints a loud warning when+// bound to a non-loopback host. The tools only expose schema+// metadata (no SQL execution, no data access), which bounds the blast+// radius, but operators must still avoid exposing it on a public+// interface.++import { randomUUID } from 'node:crypto';+import { createServer, type IncomingMessage, type Server as HttpServer, type ServerResponse } from 'node:http';+import type { AddressInfo } from 'node:net';++import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';+import { isInitializeRequest } from '@modelcontextprotocol/sdk/types.js';++import { createMcpServer } from './server.js';+import type { SchemaCache } from './schemaCache.js';++export type StartHttpServerOptions = {+ /** TCP port to listen on. Default: 3334 (Kozou v0.1 spec §7.1). */+ port?: number;+ /** Host/interface to bind. Default: 127.0.0.1 (loopback only). */+ host?: string;+ /** Path that serves the MCP Streamable HTTP endpoint. Default: '/mcp'. */+ mcpPath?: string;+ /** Prefix used in stderr log lines. Default: '[@kozou/mcp]'. */+ logPrefix?: string;+};++export type HttpServerHandle = {+ /** The actual bound port (resolves an ephemeral `0` to the real port). */+ port: number;+ /** The host the server is bound to. */+ host: string;+ /** Stop accepting connections and resolve once the server has closed. */+ close: () => Promise<void>;+};++const DEFAULT_PORT = 3334;+const DEFAULT_HOST = '127.0.0.1';+const DEFAULT_MCP_PATH = '/mcp';+const REFRESH_PATH = '/admin/refresh';++const LOOPBACK_HOSTS = new Set(['127.0.0.1', '::1', 'localhost', '::ffff:127.0.0.1']);++export function isLoopbackHost(host: string): boolean {+ return LOOPBACK_HOSTS.has(host);+}++function nonLoopbackWarning(host: string, prefix: string): string {+ return (+ `${prefix} WARNING: MCP HTTP server bound to non-loopback host "${host}".\n` ++ `${prefix} v0.1 MCP HTTP has NO authentication (spec §18.5). Anyone who can\n` ++ `${prefix} reach ${host} can read this database's schema metadata. Bind to\n` ++ `${prefix} 127.0.0.1 (the default) unless you have a trusted network and an\n` ++ `${prefix} external auth/proxy layer in front.\n`+ );+}++/**+ * Start the MCP server over Streamable HTTP, bound to the given+ * SchemaCache. Resolves once the server is listening.+ *+ * Routes:+ * - `<mcpPath>` (default `/mcp`) — MCP Streamable HTTP endpoint+ * - `POST /admin/refresh` — invalidate the schema cache+ */+export async function startHttpServer(+ cache: SchemaCache,+ opts: StartHttpServerOptions = {},+): Promise<HttpServerHandle> {+ const prefix = opts.logPrefix ?? '[@kozou/mcp]';+ const host = opts.host ?? DEFAULT_HOST;+ const port = opts.port ?? DEFAULT_PORT;+ const mcpPath = opts.mcpPath ?? DEFAULT_MCP_PATH;+@@ -2,34 +2,24 @@ // // --stdio: spins up the @kozou/mcp server with a stdio transport, reading // connection details from kozou.config.yaml / environment.-// --http: deferred to v0.1.1 per the Kozou v0.1 design spec §16.1.1 B.-// Prints a hand-off note to stderr and exits cleanly.+// --http: spins up the @kozou/mcp server with the Streamable HTTP+// transport (Kozou v0.1 spec §7.1), binding to localhost by default and+// exposing POST /admin/refresh for cache invalidation (§7.5). // // See Kozou v0.1 design spec §9.1 and §7. -import { SchemaCache, startStdioServer } from '@kozou/mcp';+import { SchemaCache, startHttpServer, startStdioServer } from '@kozou/mcp'; import { loadConfig } from '../config.js'; export type McpOptions = { stdio?: boolean; http?: boolean; port?: number;+ host?: string; config?: string; }; -const HTTP_HANDOFF_MESSAGE =- 'kozou mcp --http: HTTP transport is scheduled for v0.1.1.\n' +- ' See Kozou v0.1 design spec §16.1.1 B for the roadmap.\n' +- ' Use --stdio for now (the default).\n';- export async function mcpCommand(opts: McpOptions = {}): Promise<void> {- // --http is deferred to v0.1.1 (HTTP transport not yet implemented in- // the underlying @kozou/mcp package).- if (opts.http === true) {- process.stderr.write(HTTP_HANDOFF_MESSAGE);- return;- }- const config = await loadConfig({ path: opts.config }); const cache = new SchemaCache({@@ -38,5 +28,14 @@ export async function mcpCommand(opts: McpOptions = {}): Promise<void> { ttlMs: config.cache.ttlMs, }); + if (opts.http === true) {+ await startHttpServer(cache, {+ port: opts.port,+ host: opts.host,+ logPrefix: '[kozou mcp]',+ });+ return;+ }+ await startStdioServer(cache, { logPrefix: '[kozou mcp]' }); }@@ -5,6 +5,12 @@ export { createMcpServer } from './server.js'; export { SchemaCache, type SchemaCacheOptions } from './schemaCache.js'; export { startStdioServer, type StartStdioServerOptions } from './startStdioServer.js';+export {+ startHttpServer,+ isLoopbackHost,+ type StartHttpServerOptions,+ type HttpServerHandle,+} from './startHttpServer.js'; export { listTables } from './tools/list_tables.js'; export { describeTable } from './tools/describe_table.js'; export { listViews } from './tools/list_views.js';diff --git a/packages/mcp/src/startHttpServer.ts b/packages/mcp/src/startHttpServer.tsindex 9643e54..91cc618 100644--- a/packages/mcp/src/startHttpServer.ts+++ b/packages/mcp/src/startHttpServer.ts@@ -46,8 +46,30 @@ export type StartHttpServerOptions = { * non-loopback bind the startup warning is escalated: anyone who can reach * the host could execute exposed functions as the execution role. */ execution?: McpExecution;+ /** Override the set of `Host` header values accepted by the DNS-rebinding+ * guard (host:port form, e.g. `mcp.internal:3334`). When omitted, a loopback+ * bind accepts the loopback names on the bound port and a specific+ * non-loopback bind accepts that host:port; a bind-all address (0.0.0.0 / ::)+ * cannot be enumerated, so pass this to enable the guard there. */+ allowedHosts?: string[];+ /** Override the set of `Origin` header values accepted by the DNS-rebinding+ * guard. When omitted it mirrors the allowed hosts under `http://`. Requests+ * with no `Origin` header (the usual non-browser MCP client) are always+ * allowed; the guard only rejects a *present* Origin that is not allowed. */+ allowedOrigins?: string[];+ /** Maximum accepted request body size in bytes. The endpoint has no+ * authentication, so an unbounded body would let any reachable client drive+ * the process toward OOM. Default: 1 MiB (MCP messages are small). A request+ * whose `Content-Length` exceeds this, or that streams past it, is rejected+ * with 413. */+ maxBodyBytes?: number; }; +/** The DNS-rebinding guard: the hostnames this server accepts in the `Host`+ * (and, by default, `Origin`) header. `allowedOrigins`, when set, is an exact+ * Origin allowlist that replaces the hostname-based Origin check. */+type RebindingGuard = { hostnames: Set<string>; allowedOrigins?: Set<string> };+ export type HttpServerHandle = { /** The actual bound port (resolves an ephemeral `0` to the real port). */ port: number;@@ -61,6 +83,19 @@ const DEFAULT_PORT = 3334; const DEFAULT_HOST = '127.0.0.1'; const DEFAULT_MCP_PATH = '/mcp'; const REFRESH_PATH = '/admin/refresh';+const DEFAULT_MAX_BODY_BYTES = 1024 * 1024; // 1 MiB++/** A client-error in reading the request body (oversized, or wrong media+ * type), carrying the HTTP status to return. */+class HttpBodyError extends Error {+ constructor(+ readonly status: number,+ message: string,+ ) {+ super(message);+ this.name = 'HttpBodyError';+ }+} const LOOPBACK_HOSTS = new Set(['127.0.0.1', '::1', 'localhost', '::ffff:127.0.0.1']); @@ -68,6 +103,86 @@ export function isLoopbackHost(host: string): boolean { return LOOPBACK_HOSTS.has(host); } +const BIND_ALL_HOSTS = new Set(['0.0.0.0', '::', '']);+const LOOPBACK_HOSTNAMES = ['127.0.0.1', 'localhost', '::1'];++/** The hostname portion of a `Host` header value, lowercased (hostnames are+ * case-insensitive) with the port and any IPv6 brackets removed:+ * `127.0.0.1:3334` -> `127.0.0.1`, `[::1]:3334` -> `::1`, `LOCALHOST` ->+ * `localhost`. */+function hostnameOf(value: string): string {+ const stripped = value.startsWith('[')+ ? (() => {+ const end = value.indexOf(']');+ return end === -1 ? value : value.slice(1, end);+ })()+ : (() => {+ const colon = value.indexOf(':');+ return colon === -1 ? value : value.slice(0, colon);+ })();+ return stripped.toLowerCase();+}++/** Build the DNS-rebinding guard for a server bound to `host`. The MCP HTTP+ * transport has no authentication, so without a Host/Origin check a web page+ * in the operator's browser could rebind a name it controls to the loopback+ * address and drive this endpoint. The browser sets `Host` (and `Origin`)+ * honestly from the page URL and cannot be made to forge them, so refusing a+ * request whose hostname is not allowed defeats the rebinding vector.+ *+ * This is *only* a browser-rebinding defence — it is not network access+ * control. A non-browser client (curl, a LAN process) can send any `Host`, so+ * reachability of a no-auth server must be constrained by the network (bind /+ * publish on loopback), not by this header check.+ *+ * Matching is on the *hostname* (port-agnostic): a rebinding request carries+ * the attacker's hostname while the port is just whatever the server listens+ * on, and a port-pinned check would break behind Docker port mapping. Loopback+ * names are always accepted; a specific bound host and any `allowedHosts` are+ * added. */+export function buildRebindingGuard(+ host: string,+ opts: { allowedHosts?: string[]; allowedOrigins?: string[] } = {},+): RebindingGuard {+ const hostnames = new Set<string>(LOOPBACK_HOSTNAMES);+ if (!isLoopbackHost(host) && !BIND_ALL_HOSTS.has(host)) {+ hostnames.add(hostnameOf(host));+ }+ for (const h of opts.allowedHosts ?? []) hostnames.add(hostnameOf(h));+ const allowedOrigins =+ opts.allowedOrigins && opts.allowedOrigins.length > 0 ? new Set(opts.allowedOrigins) : undefined;+ return { hostnames, allowedOrigins };+}++/** Returns a rejection reason when the request's Host/Origin is not allowed by+ * the guard, or null when the request may proceed. The Host header must be+ * present and its hostname allowed. A *present* Origin must be allowed too+ * (exact match when `allowedOrigins` is set, else its hostname must be+ * allowed); a missing Origin — the usual non-browser MCP client — is fine. */+function validateRebindingHeaders(req: IncomingMessage, guard: RebindingGuard): string | null {+ const host = headerValue(req.headers.host);+ if (host === undefined || !guard.hostnames.has(hostnameOf(host))) {+ return 'Host header is not allowed for this server.';+ }+ const origin = headerValue(req.headers.origin);+ if (origin !== undefined) {+ if (guard.allowedOrigins !== undefined) {+ if (!guard.allowedOrigins.has(origin)) return 'Origin is not allowed for this server.';+ } else {+ let originHostname: string | undefined;+ try {+ originHostname = new URL(origin).hostname;+ } catch {+ originHostname = undefined;+ }+ if (originHostname === undefined || !guard.hostnames.has(originHostname)) {+ return 'Origin is not allowed for this server.';+ }+ }+ }+ return null;+}+ function nonLoopbackWarning(host: string, prefix: string, executionRole?: string): string { const head = `${prefix} WARNING: MCP HTTP server bound to non-loopback host "${host}".\n` +@@ -114,10 +229,23 @@ export async function startHttpServer( // Active MCP sessions, keyed by the transport-issued session id. const transports = new Map<string, StreamableHTTPServerTransport>(); + // The DNS-rebinding guard is hostname-based and known up front; build it here+ // so the request handler (which only runs after listen()) closes over it.+ const guard = buildRebindingGuard(host, opts);++ const maxBodyBytes = opts.maxBodyBytes ?? DEFAULT_MAX_BODY_BYTES;+ const httpServer = createServer((req, res) => {- handleRequest(req, res, cache, mcpPath, transports, opts.execution).catch((err) => {- respondError(res, 500, err instanceof Error ? err.message : String(err));- });+ handleRequest(req, res, cache, mcpPath, transports, opts.execution, guard, maxBodyBytes).catch(+ (err) => {+ // Never echo the raw error to the client (it can carry stack/database+ // detail); log it server-side and return a generic message.+ process.stderr.write(+ `${prefix} request failed: ${err instanceof Error ? err.message : String(err)}\n`,+ );+ respondError(res, 500, 'Internal server error.');+ },+ ); }); await new Promise<void>((resolve, reject) => {@@ -134,6 +262,10 @@ export async function startHttpServer( `${prefix} MCP HTTP listening on http://${host}:${boundPort}` + ` (MCP: ${mcpPath}, refresh: POST ${REFRESH_PATH})\n`, );+ process.stderr.write(+ `${prefix} DNS-rebinding guard: accepting Host names ${[...guard.hostnames].join(', ')}` ++ ` (set allowedHosts to add more)\n`,+ ); return { port: boundPort,@@ -155,7 +287,27 @@ async function handleRequest( mcpPath: string, transports: Map<string, StreamableHTTPServerTransport>, execution: McpExecution | undefined,+ guard: RebindingGuard,+ maxBodyBytes: number, ): Promise<void> {+ // DNS-rebinding guard: reject requests whose Host/Origin is not allowed+ // before doing any work, covering every route (the MCP endpoint and+ // /admin/refresh, both of which a rebound page could otherwise drive).+ const rejection = validateRebindingHeaders(req, guard);+ if (rejection !== null) {+ respondError(res, 403, rejection);+ return;+ }++ // Reject an over-large body up front when the client declares its size, so a+ // huge declared payload is refused before any of it is buffered. This guards+ // every route, including requests the SDK transport reads itself.+ const declared = Number(headerValue(req.headers['content-length']));+ if (Number.isFinite(declared) && declared > maxBodyBytes) {+ respondError(res, 413, 'Request body too large.');+ return;+ }+ const url = new URL(req.url ?? '/', 'http://localhost'); if (url.pathname === REFRESH_PATH) {@@ -170,7 +322,7 @@ async function handleRequest( } if (url.pathname === mcpPath) {- await handleMcp(req, res, cache, transports, execution);+ await handleMcp(req, res, cache, transports, execution, maxBodyBytes); return; } @@ -183,6 +335,7 @@ async function handleMcp( cache: SchemaCache, transports: Map<string, StreamableHTTPServerTransport>, execution: McpExecution | undefined,+ maxBodyBytes: number, ): Promise<void> { const sessionId = headerValue(req.headers['mcp-session-id']); @@ -193,6 +346,30 @@ async function handleMcp( respondError(res, 404, `Unknown MCP session: ${sessionId}`); return; }+ // A POST carries a JSON-RPC message body. Read it through the capped reader+ // here (rather than letting the SDK transport buffer the raw stream itself)+ // so an established session cannot stream an unbounded body — the+ // Content-Length short-circuit above only catches a *declared* size. GET+ // (the SSE stream) and DELETE (session teardown) carry no body, so they go+ // straight to the transport.+ if (req.method === 'POST') {+ let body: unknown;+ try {+ body = await readJsonBody(req, maxBodyBytes);+ } catch (err) {+ if (err instanceof HttpBodyError) {+ respondError(res, err.status, err.message);+ return;+ }+ throw err;+ }+ if (body === undefined) {+ respondError(res, 400, 'Bad Request: empty or invalid JSON body.');+ return;+ }+ await existing.handleRequest(req, res, body);+ return;+ } await existing.handleRequest(req, res); return; }@@ -200,7 +377,16 @@ async function handleMcp( // No session id: this must be an initialize request that opens a new // session. The body is read once here so we can both classify it and // hand it to the transport (which would otherwise consume the stream).- const body = await readJsonBody(req);+ let body: unknown;+ try {+ body = await readJsonBody(req, maxBodyBytes);+ } catch (err) {+ if (err instanceof HttpBodyError) {+ respondError(res, err.status, err.message);+ return;+ }+ throw err;+ } if (!isInitializeRequest(body)) { respondError( res,@@ -233,10 +419,26 @@ function headerValue(value: string | string[] | undefined): string | undefined { return value; } -async function readJsonBody(req: IncomingMessage): Promise<unknown> {+async function readJsonBody(req: IncomingMessage, maxBodyBytes: number): Promise<unknown> {+ // Reject a non-JSON media type up front (when one is declared). A missing+ // Content-Type is tolerated — some clients omit it on a JSON POST.+ const contentType = headerValue(req.headers['content-type']);+ if (contentType !== undefined && !/^application\/json\b/i.test(contentType)) {+ throw new HttpBodyError(415, 'Unsupported Media Type: expected application/json.');+ }+ const chunks: Buffer[] = [];+ let total = 0; for await (const chunk of req) {- chunks.push(chunk as Buffer);+ const buf = chunk as Buffer;+ total += buf.length;+ // Enforce the cap while streaming so a chunked / Content-Length-less body+ // cannot grow the buffer past the limit. Throwing exits the async iterator+ // (which stops reading); the caller maps this to a 413 response.+ if (total > maxBodyBytes) {+ throw new HttpBodyError(413, 'Request body too large.');+ }+ chunks.push(buf); } if (chunks.length === 0) return undefined; const raw = Buffer.concat(chunks).toString('utf8');diff --git a/packages/kozou/src/config.ts b/packages/kozou/src/config.tsindex 12937e7..70f9c38 100644--- a/packages/kozou/src/config.ts+++ b/packages/kozou/src/config.ts@@ -26,17 +26,22 @@ import { z } from 'zod'; // applies. `.prefault({})` therefore reproduces the zod 3 behaviour of // "absent section -> object filled entirely from inner defaults". +// The Admin UI and MCP HTTP server have no authentication of their own, so they+// default to loopback — reachable only from the machine running `kozou dev`. To+// bind all interfaces (e.g. inside a container so a port mapping can reach it),+// set KOZOU_UI_HOST / KOZOU_MCP_HTTP_HOST=0.0.0.0 (honoured in loadConfig) and+// publish the host port on loopback — see the docker-compose template. const uiServerSchema = z .object({ port: z.number().int().min(0).max(65_535).default(3333),- host: z.string().min(1).default('0.0.0.0'),+ host: z.string().min(1).default('127.0.0.1'), }) .prefault({}); const mcpHttpServerSchema = z .object({ port: z.number().int().min(0).max(65_535).default(3334),- host: z.string().min(1).default('0.0.0.0'),+ host: z.string().min(1).default('127.0.0.1'), }) .prefault({}); @@ -355,6 +360,36 @@ function injectDatabaseUrlFromEnv(raw: unknown, env: NodeJS.ProcessEnv): unknown return obj; } +// Bind-host overrides from the environment for the no-auth dev surfaces. They+// default to loopback (see uiServerSchema / mcpHttpServerSchema); a container+// sets KOZOU_UI_HOST / KOZOU_MCP_HTTP_HOST to 0.0.0.0 so a (loopback-published)+// port mapping can reach them. Honoured even when there is no config file —+// `${VAR}` expansion only reaches values written in the YAML.+function injectServerHostsFromEnv(raw: unknown, env: NodeJS.ProcessEnv): unknown {+ const uiHost = env.KOZOU_UI_HOST;+ const mcpHost = env.KOZOU_MCP_HTTP_HOST;+ if (!uiHost && !mcpHost) return raw;+ const asObj = (v: unknown): Record<string, unknown> =>+ v !== null && typeof v === 'object' ? { ...(v as Record<string, unknown>) } : {};++ const obj = asObj(raw);+ const server = asObj(obj.server);+ if (uiHost) {+ const ui = asObj(server.ui);+ ui.host = uiHost;+ server.ui = ui;+ }+ if (mcpHost) {+ const mcp = asObj(server.mcp);+ const http = asObj(mcp.http);+ http.host = mcpHost;+ mcp.http = http;+ server.mcp = mcp;+ }+ obj.server = server;+ return obj;+}+ function splitList(value: string | undefined): string[] | undefined { if (value === undefined) return undefined; const items = value@@ -462,9 +497,12 @@ export async function loadConfig(opts: LoadConfigOptions = {}): Promise<KozouCon const expanded = expandEnvVars(withDbDefault, env); // Build `auth` from KOZOU_JWT_* env after expansion (env secrets verbatim). const withAuth = injectAuthFromEnv(expanded, env);+ // Apply KOZOU_UI_HOST / KOZOU_MCP_HTTP_HOST overrides (e.g. a container that+ // needs to bind 0.0.0.0), even with no config file.+ const withHosts = injectServerHostsFromEnv(withAuth, env); try {- return configSchema.parse(withAuth);+ return configSchema.parse(withHosts); } catch (err) { if (err instanceof z.ZodError) { throw new KozouConfigError(AI-assisted change 6c41eee680bff771312c6287f0b5ee218c7641db07954440722bd8c5cdead00f · Fix 8b93b94bc250d24a160e11a864e3f481a4dbcf7ac805005f8159451cf12ea962