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); + }); +}