From d292432afdb09a656231a1bf724c96f4b47cc84a Mon Sep 17 00:00:00 2001 From: autoantohaki Date: Wed, 21 Oct 2026 17:56:18 -0300 Subject: [PATCH] fix(http): validate Host and Origin to block DNS rebinding createMcpHandler is deliberately validation-free, and the template put nothing in front of it. A page on any website could rebind its domain to 127.0.0.1 and drive the local MCP endpoint from the user's browser, calling every registered tool. The MCP transport specification requires servers to validate Origin for this reason. Check the Host header and any browser Origin against allowlists before the MCP handler runs and answer 403 otherwise. Both lists default to the localhost names and are configurable through MCP_ALLOWED_HOSTS and MCP_ALLOWED_ORIGINS for deployments reached under a public name. --- .env.example | 2 ++ CHANGELOG.md | 1 + docs/configuration.md | 20 +++++++----- docs/deployment.md | 2 +- src/config.ts | 13 ++++++++ src/http.ts | 19 +++++++++++- tests/config.test.ts | 8 +++++ tests/http.test.ts | 72 +++++++++++++++++++++++++++++++++++++++++++ 8 files changed, 127 insertions(+), 10 deletions(-) diff --git a/.env.example b/.env.example index cfe32c3..ca1a07a 100644 --- a/.env.example +++ b/.env.example @@ -4,3 +4,5 @@ MCP_PORT=3333 MCP_PATH=/mcp MCP_LOG_LEVEL=info MCP_HTTP_JSON_RESPONSE=false +MCP_ALLOWED_HOSTS=localhost,127.0.0.1,[::1] +MCP_ALLOWED_ORIGINS=localhost,127.0.0.1,[::1] diff --git a/CHANGELOG.md b/CHANGELOG.md index 9b289dd..6e8b617 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,3 +6,4 @@ This project follows Semantic Versioning. Release notes describe protocol suppor - Initial TypeScript MCP server template. - HTTP: a request with a malformed `Host` header or request target no longer crashes the process. +- HTTP: the MCP endpoint validates `Host` and `Origin` headers against `MCP_ALLOWED_HOSTS` and `MCP_ALLOWED_ORIGINS` (localhost by default) to block DNS rebinding. diff --git a/docs/configuration.md b/docs/configuration.md index 757fab6..ebd7d3a 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -1,12 +1,16 @@ # Configuration -| Variable | Default | Description | -| ------------------------ | ----------- | ----------------------------------- | -| `MCP_TRANSPORT` | `stdio` | `stdio` or `http` | -| `MCP_HOST` | `127.0.0.1` | HTTP bind address | -| `MCP_PORT` | `3333` | HTTP port | -| `MCP_PATH` | `/mcp` | Streamable HTTP endpoint | -| `MCP_LOG_LEVEL` | `info` | Pino log level | -| `MCP_HTTP_JSON_RESPONSE` | `false` | Return terminal JSON instead of SSE | +| Variable | Default | Description | +| ------------------------ | --------------------------- | ------------------------------------------------- | +| `MCP_TRANSPORT` | `stdio` | `stdio` or `http` | +| `MCP_HOST` | `127.0.0.1` | HTTP bind address | +| `MCP_PORT` | `3333` | HTTP port | +| `MCP_PATH` | `/mcp` | Streamable HTTP endpoint | +| `MCP_LOG_LEVEL` | `info` | Pino log level | +| `MCP_HTTP_JSON_RESPONSE` | `false` | Return terminal JSON instead of SSE | +| `MCP_ALLOWED_HOSTS` | `localhost,127.0.0.1,[::1]` | Comma-separated `Host` names the endpoint accepts | +| `MCP_ALLOWED_ORIGINS` | `localhost,127.0.0.1,[::1]` | Comma-separated browser `Origin` hostnames | Command-line values override environment values for transport, host, and port. + +The MCP endpoint answers `403` when the `Host` header or a browser's `Origin` header names a hostname outside these lists. This blocks DNS rebinding attacks against servers on a developer's machine. Entries are hostnames without scheme or port; write IPv6 addresses in brackets. Requests without an `Origin` header, which is how non-browser MCP clients connect, are not affected by the origin list. When the server is reached through a public name, add that name to `MCP_ALLOWED_HOSTS`, and to `MCP_ALLOWED_ORIGINS` if browsers call it. Health and readiness routes are not restricted. diff --git a/docs/deployment.md b/docs/deployment.md index 2ab3aca..787578b 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -2,7 +2,7 @@ The included container runs the HTTP transport as an unprivileged user. It exposes port 3333 and reports health through `/healthz`. -Public deployments need an authentication and authorization policy suited to their tools. Put TLS at the edge, limit accepted origins and hosts, rate-limit expensive handlers, and keep the default loopback binding during local development. +Public deployments need an authentication and authorization policy suited to their tools. Put TLS at the edge, set `MCP_ALLOWED_HOSTS` and `MCP_ALLOWED_ORIGINS` to the names clients use (see [Configuration](configuration.md)), rate-limit expensive handlers, and keep the default loopback binding during local development. Build and run: diff --git a/src/config.ts b/src/config.ts index ceed8c7..991b934 100644 --- a/src/config.ts +++ b/src/config.ts @@ -1,6 +1,13 @@ import { z } from "zod"; const booleanFromString = z.preprocess((value) => value === true || value === "true", z.boolean()); +const hostnameList = z.string().transform((value) => + value + .split(",") + .map((item) => item.trim()) + .filter(Boolean), +); +const localhostNames = ["localhost", "127.0.0.1", "[::1]"]; const environmentSchema = z.object({ MCP_TRANSPORT: z.enum(["stdio", "http"]).default("stdio"), @@ -11,6 +18,8 @@ const environmentSchema = z.object({ .enum(["fatal", "error", "warn", "info", "debug", "trace", "silent"]) .default("info"), MCP_HTTP_JSON_RESPONSE: booleanFromString.default(false), + MCP_ALLOWED_HOSTS: hostnameList.default(() => [...localhostNames]), + MCP_ALLOWED_ORIGINS: hostnameList.default(() => [...localhostNames]), }); export type AppConfig = { @@ -20,6 +29,8 @@ export type AppConfig = { path: string; logLevel: z.infer["MCP_LOG_LEVEL"]; httpJsonResponse: boolean; + allowedHosts: string[]; + allowedOrigins: string[]; }; export function loadConfig(environment: NodeJS.ProcessEnv = process.env): AppConfig { @@ -35,5 +46,7 @@ export function loadConfig(environment: NodeJS.ProcessEnv = process.env): AppCon path: parsed.data.MCP_PATH, logLevel: parsed.data.MCP_LOG_LEVEL, httpJsonResponse: parsed.data.MCP_HTTP_JSON_RESPONSE, + allowedHosts: parsed.data.MCP_ALLOWED_HOSTS, + allowedOrigins: parsed.data.MCP_ALLOWED_ORIGINS, }; } diff --git a/src/http.ts b/src/http.ts index cd34767..b8f56ce 100644 --- a/src/http.ts +++ b/src/http.ts @@ -1,5 +1,9 @@ import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http"; -import { createMcpHandler } from "@modelcontextprotocol/server"; +import { + createMcpHandler, + validateHostHeader, + validateOriginHeader, +} from "@modelcontextprotocol/server"; import { toNodeHandler, type NodeIncomingMessageLike } from "@modelcontextprotocol/node"; import type { Logger } from "pino"; import type { AppConfig } from "./config.js"; @@ -55,6 +59,19 @@ export async function startHttp(config: AppConfig, logger: Logger): Promise { path: "/mcp", logLevel: "info", httpJsonResponse: false, + allowedHosts: ["localhost", "127.0.0.1", "[::1]"], + allowedOrigins: ["localhost", "127.0.0.1", "[::1]"], }); }); + it("parses comma-separated host and origin allowlists", () => { + expect( + loadConfig({ MCP_ALLOWED_HOSTS: "mcp.example.com, localhost", MCP_ALLOWED_ORIGINS: "" }), + ).toMatchObject({ allowedHosts: ["mcp.example.com", "localhost"], allowedOrigins: [] }); + }); + it("accepts HTTP configuration", () => { expect( loadConfig({ MCP_TRANSPORT: "http", MCP_PORT: "4400", MCP_HTTP_JSON_RESPONSE: "true" }), diff --git a/tests/http.test.ts b/tests/http.test.ts index 5416408..b67aeb8 100644 --- a/tests/http.test.ts +++ b/tests/http.test.ts @@ -1,3 +1,4 @@ +import { request as httpRequest } from "node:http"; import { connect } from "node:net"; import { afterEach, describe, expect, it } from "vitest"; import pino from "pino"; @@ -59,6 +60,43 @@ describe("HTTP runtime", () => { const health = await fetch("http://127.0.0.1:33434/healthz"); expect(health.status).toBe(200); }); + + it("rejects MCP requests from hosts and origins outside the allowlist", async () => { + const config = loadConfig({ + MCP_TRANSPORT: "http", + MCP_PORT: "33436", + MCP_LOG_LEVEL: "silent", + }); + runtime = await startHttp(config, pino({ level: "silent" })); + const rebound = await postMcp(33436, { host: "attacker.example:33436" }); + const crossOrigin = await postMcp(33436, { + host: "127.0.0.1:33436", + origin: "https://attacker.example", + }); + const local = await postMcp(33436, { + host: "localhost:33436", + origin: "http://localhost:5173", + }); + expect(rebound.status).toBe(403); + expect(rebound.body).toContain("Invalid Host: attacker.example"); + expect(crossOrigin.status).toBe(403); + expect(crossOrigin.body).toContain("Invalid Origin: attacker.example"); + expect(local.status).not.toBe(403); + }); + + it("accepts hosts added to the allowlist", async () => { + const config = loadConfig({ + MCP_TRANSPORT: "http", + MCP_PORT: "33437", + MCP_LOG_LEVEL: "silent", + MCP_ALLOWED_HOSTS: "mcp.example.com", + }); + runtime = await startHttp(config, pino({ level: "silent" })); + const allowed = await postMcp(33437, { host: "mcp.example.com" }); + const loopback = await postMcp(33437, { host: "127.0.0.1:33437" }); + expect(allowed.status).not.toBe(403); + expect(loopback.status).toBe(403); + }); }); function rawRequest(port: number, payload: string): Promise { @@ -71,3 +109,37 @@ function rawRequest(port: number, payload: string): Promise { socket.once("error", reject); }); } + +function postMcp( + port: number, + headers: Record, +): Promise<{ status: number; body: string }> { + const body = JSON.stringify({ jsonrpc: "2.0", id: 1, method: "ping" }); + return new Promise((resolve, reject) => { + const request = httpRequest( + { + host: "127.0.0.1", + port, + path: "/mcp", + method: "POST", + headers: { + ...headers, + accept: "application/json, text/event-stream", + "content-type": "application/json", + }, + }, + (response) => { + let text = ""; + response.setEncoding("utf8"); + response.on("data", (chunk: string) => { + text += chunk; + }); + response.on("end", () => { + resolve({ status: response.statusCode ?? 0, body: text }); + }); + }, + ); + request.once("error", reject); + request.end(body); + }); +}