diff --git a/CHANGELOG.md b/CHANGELOG.md index 7a4042d..3f0c3fb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,13 @@ All notable changes to `toolception` are listed here. Versions follow [semver](h Releases before 0.7.0 are described in [GitHub Releases](https://github.com/code-rabi/toolception/releases). +## [Unreleased] + +### Added + +- `http.allowedOrigins` validates the `Origin` header on the `/mcp` endpoints, as the MCP spec requires. A request with an `Origin` outside the list gets `403`; requests without an `Origin` header are always allowed. When `cors` is on, only the listed origins get CORS access. Not set by default, so existing deployments behave as before. + - **Migration:** none needed. To turn it on, pass `http: { allowedOrigins: ["https://app.example.com"] }`. + ## [0.7.0] ### Breaking diff --git a/README.md b/README.md index 22868f1..5006077 100644 --- a/README.md +++ b/README.md @@ -962,9 +962,14 @@ Notes #### options.http (optional) -`{ host?: string; port?: number; basePath?: string; cors?: boolean; logger?: boolean; customEndpoints?: CustomEndpointDefinition[] }` +`{ host?: string; port?: number; basePath?: string; cors?: boolean; logger?: boolean; allowedOrigins?: string[]; customEndpoints?: CustomEndpointDefinition[] }` - Fastify transport configuration. Defaults: host `0.0.0.0`, port `3000`, basePath `/`, CORS enabled, logger disabled. +- `allowedOrigins`: Origins allowed to call `/mcp` (POST, GET, DELETE), e.g. `["https://app.example.com"]`. The MCP spec requires servers to validate `Origin` as a defence against DNS rebinding, so set this on any server that browsers could reach, and in particular on one bound to localhost. + - Not set (default): no check, as before. Set: a request whose `Origin` is not listed gets `403`. + - Requests with no `Origin` header (SDK, CLI and server-to-server clients) are always allowed, so non-browser clients keep working. `[]` allows only those. + - Entries are compared by scheme, host and port, case-insensitively. An entry that is not an origin (such as `"*"`) throws at startup. The request's `Origin` must be a bare origin as browsers send it, so a value with a path, userinfo, query or explicit default port is refused. + - With `cors` on, only the listed origins also get CORS access; the other endpoints (`/healthz`, `/tools`, custom endpoints) are not Origin-checked. - `customEndpoints`: Optional array of custom HTTP endpoints to register alongside MCP protocol endpoints. See [Custom HTTP endpoints](#custom-http-endpoints) for details. #### options.createServer (optional) diff --git a/src/http/AGENTS.md b/src/http/AGENTS.md index e9628a2..7f30ebc 100644 --- a/src/http/AGENTS.md +++ b/src/http/AGENTS.md @@ -7,6 +7,7 @@ Provides Fastify-based HTTP transport for the MCP protocol. Handles SSE streams, ## Key Components - **FastifyTransport** (`FastifyTransport.ts`) — Main HTTP transport. Per-client bundles via ClientResourceCache, optional SessionContextResolver for context differentiation. +- **Origin validation** (`originValidation.ts`) — `normalizeAllowedOrigins()` + `createOriginGuard()`; each transport applies the guard as an `onRequest` hook on `/mcp` POST/GET/DELETE only, when `allowedOrigins` is set. - **Custom Endpoints** (`customEndpoints.ts`, `endpointRegistration.ts`) — Type-safe endpoint definitions with Zod schemas. `defineEndpoint()` / `definePermissionAwareEndpoint()` helpers. ## Endpoints @@ -25,7 +26,8 @@ Provides Fastify-based HTTP transport for the MCP protocol. Handles SSE streams, 1. **`mcp-client-id` header required** — All `/mcp` endpoints reject without it (400). Custom endpoints auto-generate `anon-${UUID}` fallback instead. 2. **Session created on POST /mcp initialize** — `isInitializeRequest()` from MCP SDK detects first-contact requests. New session ID generated via `randomUUID()`, stored in `bundle.sessions` Map. 3. **Cache key uses `resolveSessionContext()`** — All three `/mcp` handlers (POST, GET, DELETE) must derive the cache key through the same `resolveSessionContext()` path. Using plain `clientId` causes cache misses when session context is active. -4. **Reserved paths cannot be overridden** — `/mcp`, `/healthz`, `/tools`, `/.well-known/mcp-config` are registered before custom endpoints. +4. **Origin check is opt-in and `/mcp`-only** — `allowedOrigins` unset means no check (backward compatible). When set: no `Origin` header → allowed (non-browser clients), listed → allowed, anything else (including `null`, which can never be listed) → 403 before the `mcp-client-id` check. Invalid entries throw at transport construction. Both transports must keep the same behaviour, and `cors` narrows to the same list. +5. **Reserved paths cannot be overridden** — `/mcp`, `/healthz`, `/tools`, `/.well-known/mcp-config` are registered before custom endpoints. ## Session Lifecycle @@ -66,6 +68,7 @@ Provides Fastify-based HTTP transport for the MCP protocol. Handles SSE streams, - Registering endpoints on reserved paths (throws) - Sending MCP protocol requests without `mcp-client-id` header (returns 400) - Blocking on SSE handlers (should be async) +- Adding a `*` wildcard to `allowedOrigins` (leave it unset to allow all; the guard rejects non-origin entries on purpose) ## Dependencies diff --git a/src/http/FastifyTransport.ts b/src/http/FastifyTransport.ts index 29f88b0..5074f3b 100644 --- a/src/http/FastifyTransport.ts +++ b/src/http/FastifyTransport.ts @@ -20,6 +20,11 @@ import type { CustomEndpointDefinition, } from "./http.types.js"; import { registerCustomEndpoints } from "./http.utils.js"; +import { + createOriginGuard, + normalizeAllowedOrigins, + type OriginGuard, +} from "./originValidation.js"; const mcpClientIdSchema = z .string({ message: "Missing required mcp-client-id header" }) @@ -34,8 +39,11 @@ export class FastifyTransport { cors: boolean; logger: boolean; app?: FastifyInstance; + allowedOrigins?: string[]; customEndpoints?: CustomEndpointDefinition[]; }; + /** Route options for the `/mcp` endpoints; carries the Origin check when configured. */ + private readonly mcpRouteOptions: { onRequest?: OriginGuard }; private readonly defaultManager: DynamicToolManager; private readonly createBundle: CreateBundleCallback; private readonly sessionContextResolver?: SessionContextResolver; @@ -74,8 +82,14 @@ export class FastifyTransport { cors: options.cors ?? true, logger: options.logger ?? false, app: options.app, + allowedOrigins: options.allowedOrigins + ? normalizeAllowedOrigins(options.allowedOrigins) + : undefined, customEndpoints: options.customEndpoints, }; + this.mcpRouteOptions = this.options.allowedOrigins + ? { onRequest: createOriginGuard(this.options.allowedOrigins) } + : {}; this.configSchema = configSchema; } @@ -95,6 +109,7 @@ export class FastifyTransport { cors(value: boolean) { opts.cors = value; return builder; }, logger(value: boolean) { opts.logger = value; return builder; }, app(value: FastifyInstance) { opts.app = value; return builder; }, + allowedOrigins(value: readonly string[]) { opts.allowedOrigins = value; return builder; }, customEndpoints(value: CustomEndpointDefinition[]) { opts.customEndpoints = value; return builder; }, configSchema(value: object) { _configSchema = value; return builder; }, sessionContextResolver(value: SessionContextResolver) { _sessionContextResolver = value; return builder; }, @@ -108,7 +123,7 @@ export class FastifyTransport { if (this.app) return; const app = this.options.app ?? Fastify({ logger: this.options.logger }); if (this.options.cors) { - await app.register(cors, { origin: true }); + await app.register(cors, { origin: this.options.allowedOrigins ?? true }); } const base = this.normalizeBasePath(this.options.basePath); @@ -184,6 +199,7 @@ export class FastifyTransport { private registerMcpPostEndpoint(app: FastifyInstance, base: string): void { app.post( `${base}/mcp`, + this.mcpRouteOptions, async (req: FastifyRequest, reply: FastifyReply) => { const parseResult = mcpClientIdSchema.safeParse( req.headers["mcp-client-id"] @@ -272,7 +288,7 @@ export class FastifyTransport { * @param base - Base path for routes */ private registerMcpGetEndpoint(app: FastifyInstance, base: string): void { - app.get(`${base}/mcp`, async (req: FastifyRequest, reply: FastifyReply) => { + app.get(`${base}/mcp`, this.mcpRouteOptions, async (req: FastifyRequest, reply: FastifyReply) => { const clientIdHeader = ( req.headers["mcp-client-id"] as string | undefined )?.trim(); @@ -310,6 +326,7 @@ export class FastifyTransport { private registerMcpDeleteEndpoint(app: FastifyInstance, base: string): void { app.delete( `${base}/mcp`, + this.mcpRouteOptions, async (req: FastifyRequest, reply: FastifyReply) => { const clientIdHeader = ( req.headers["mcp-client-id"] as string | undefined diff --git a/src/http/http.types.ts b/src/http/http.types.ts index dfa3c71..5cffd37 100644 --- a/src/http/http.types.ts +++ b/src/http/http.types.ts @@ -11,6 +11,25 @@ export interface FastifyTransportOptions { cors?: boolean; logger?: boolean; app?: FastifyInstance; + /** + * Origins allowed to call the `/mcp` endpoints (POST, GET, DELETE). The MCP + * spec requires servers to validate `Origin` so a malicious web page cannot + * reach a local server through DNS rebinding. + * + * - Not set (default): no check, as in earlier versions. + * - Set: a request whose `Origin` header is not listed gets `403`. Requests + * without an `Origin` header (CLI, SDK and server-to-server clients) are + * always allowed. `[]` therefore allows only clients that send no `Origin`. + * + * Entries are origins such as `"https://app.example.com"`, compared by + * scheme, host and port; case and default ports are normalized. Invalid + * entries throw when the transport is created. When `cors` is on, only + * these origins are also granted CORS access. + * + * Custom endpoints, `/healthz`, `/tools` and `/.well-known/mcp-config` are + * not covered. + */ + allowedOrigins?: readonly string[]; /** * Optional custom HTTP endpoints to register alongside MCP protocol endpoints. * Allows adding REST-like endpoints with Zod validation and type inference. diff --git a/src/http/originValidation.ts b/src/http/originValidation.ts new file mode 100644 index 0000000..4828258 --- /dev/null +++ b/src/http/originValidation.ts @@ -0,0 +1,82 @@ +import type { FastifyReply, FastifyRequest } from "fastify"; + +/** + * Reduces an origin string to `scheme://host[:port]` in lower case, with + * default ports dropped. Built from `protocol` and `host` rather than + * `URL.origin`, which is `"null"` for non-http schemes, so entries like + * `vscode-webview://abc` can be listed. The host is lower-cased explicitly + * because `URL` keeps its case for non-http schemes. + * + * @param value - Raw origin, such as the `Origin` header or an allowlist entry + * @returns The canonical origin, or `undefined` when `value` is not an origin + */ +export function normalizeOrigin(value: string): string | undefined { + try { + const url = new URL(value); + if (!url.host) return undefined; + return `${url.protocol}//${url.host.toLowerCase()}`; + } catch { + return undefined; + } +} + +/** + * Normalizes an allowlist, failing fast on entries that are not origins. + * + * @param allowedOrigins - Entries as configured by the caller + * @returns The canonical origins + * @throws When an entry cannot be parsed as an origin + */ +export function normalizeAllowedOrigins( + allowedOrigins: readonly string[] +): string[] { + return allowedOrigins.map((entry) => { + const normalized = normalizeOrigin(entry); + if (!normalized) { + throw new Error( + `Invalid allowedOrigins entry "${entry}": expected an origin such as "https://app.example.com"` + ); + } + return normalized; + }); +} + +/** Fastify `onRequest` hook that answers `403` for a disallowed `Origin`. */ +export type OriginGuard = ( + req: FastifyRequest, + reply: FastifyReply +) => Promise; + +/** + * Builds a Fastify `onRequest` hook that enforces the MCP spec's Origin check + * (protection against DNS rebinding) on the MCP endpoints. + * + * Requests without an `Origin` header are always allowed: they come from + * non-browser clients, which are not the threat this check addresses. A + * request that carries an `Origin` outside the allowlist gets `403`. The + * header must already be a bare origin (no path, userinfo, query or explicit + * default port), which is what browsers send; anything looser is refused. + * Letter case is ignored. + * + * @param allowedOrigins - Canonical origins from {@link normalizeAllowedOrigins} + * @returns The hook + */ +export function createOriginGuard(allowedOrigins: readonly string[]): OriginGuard { + const allowed = new Set(allowedOrigins); + return async function originGuard( + req: FastifyRequest, + reply: FastifyReply + ): Promise { + const header = req.headers.origin; + if (header === undefined) return undefined; + const origin = normalizeOrigin(header); + if (origin && header.toLowerCase() === origin && allowed.has(origin)) { + return undefined; + } + return reply.code(403).send({ + jsonrpc: "2.0", + error: { code: -32000, message: "Forbidden: Origin not allowed" }, + id: null, + }); + }; +} diff --git a/src/permissions/AGENTS.md b/src/permissions/AGENTS.md index 896e459..ef99356 100644 --- a/src/permissions/AGENTS.md +++ b/src/permissions/AGENTS.md @@ -18,6 +18,7 @@ Provides per-client access control for toolsets. Supports header-based and confi 2. **Header lookup is case-insensitive** — Per RFC 7230. 3. **Fail-secure resolution** — Invalid permissions return empty array, not error. Client gets no toolsets. 4. **All toolsets fail = throw** — If every requested toolset fails to enable, it's likely a config error. Partial success continues with warning. +5. **Same Origin check as FastifyTransport** — `allowedOrigins` guards `/mcp` POST/GET/DELETE via the shared `src/http/originValidation.ts`; keep the two transports in step. ## Resolution Priority (config source) diff --git a/src/permissions/PermissionAwareFastifyTransport.ts b/src/permissions/PermissionAwareFastifyTransport.ts index 5936cc2..324a10f 100644 --- a/src/permissions/PermissionAwareFastifyTransport.ts +++ b/src/permissions/PermissionAwareFastifyTransport.ts @@ -19,6 +19,11 @@ import type { } from "./permissions.types.js"; import type { CustomEndpointDefinition } from "../http/http.types.js"; import { registerCustomEndpoints } from "../http/http.utils.js"; +import { + createOriginGuard, + normalizeAllowedOrigins, + type OriginGuard, +} from "../http/originValidation.js"; const mcpClientIdSchema = z .string({ message: "Missing required mcp-client-id header" }) @@ -33,8 +38,11 @@ export class PermissionAwareFastifyTransport { cors: boolean; logger: boolean; app?: FastifyInstance; + allowedOrigins?: string[]; customEndpoints?: CustomEndpointDefinition[]; }; + /** Route options for the `/mcp` endpoints; carries the Origin check when configured. */ + readonly #mcpRouteOptions: { onRequest?: OriginGuard }; private readonly defaultManager: DynamicToolManager; private readonly createPermissionAwareBundle: ( context: ClientRequestContext @@ -73,8 +81,14 @@ export class PermissionAwareFastifyTransport { cors: options.cors ?? true, logger: options.logger ?? false, app: options.app, + allowedOrigins: options.allowedOrigins + ? normalizeAllowedOrigins(options.allowedOrigins) + : undefined, customEndpoints: options.customEndpoints, }; + this.#mcpRouteOptions = this.options.allowedOrigins + ? { onRequest: createOriginGuard(this.options.allowedOrigins) } + : {}; this.configSchema = configSchema; } @@ -92,6 +106,7 @@ export class PermissionAwareFastifyTransport { cors(value: boolean) { opts.cors = value; return builder; }, logger(value: boolean) { opts.logger = value; return builder; }, app(value: FastifyInstance) { opts.app = value; return builder; }, + allowedOrigins(value: readonly string[]) { opts.allowedOrigins = value; return builder; }, customEndpoints(value: CustomEndpointDefinition[]) { opts.customEndpoints = value; return builder; }, configSchema(value: object) { _configSchema = value; return builder; }, build() { return new PermissionAwareFastifyTransport(_defaultManager, _createPermissionAwareBundle, opts, _configSchema); }, @@ -103,7 +118,7 @@ export class PermissionAwareFastifyTransport { if (this.app) return; const app = this.options.app ?? Fastify({ logger: this.options.logger }); if (this.options.cors) { - await app.register(cors, { origin: true }); + await app.register(cors, { origin: this.options.allowedOrigins ?? true }); } const base = this.#normalizeBasePath(this.options.basePath); @@ -270,6 +285,7 @@ export class PermissionAwareFastifyTransport { #registerMcpPostEndpoint(app: FastifyInstance, base: string): void { app.post( `${base}/mcp`, + this.#mcpRouteOptions, async (req: FastifyRequest, reply: FastifyReply) => { // Validate mcp-client-id header const parseResult = mcpClientIdSchema.safeParse( @@ -376,7 +392,7 @@ export class PermissionAwareFastifyTransport { * @param base - Base path for routes */ #registerMcpGetEndpoint(app: FastifyInstance, base: string): void { - app.get(`${base}/mcp`, async (req: FastifyRequest, reply: FastifyReply) => { + app.get(`${base}/mcp`, this.#mcpRouteOptions, async (req: FastifyRequest, reply: FastifyReply) => { const clientIdHeader = ( req.headers["mcp-client-id"] as string | undefined )?.trim(); @@ -413,6 +429,7 @@ export class PermissionAwareFastifyTransport { #registerMcpDeleteEndpoint(app: FastifyInstance, base: string): void { app.delete( `${base}/mcp`, + this.#mcpRouteOptions, async (req: FastifyRequest, reply: FastifyReply) => { const clientIdHeader = ( req.headers["mcp-client-id"] as string | undefined diff --git a/src/permissions/permissions.types.ts b/src/permissions/permissions.types.ts index 258682a..89485c6 100644 --- a/src/permissions/permissions.types.ts +++ b/src/permissions/permissions.types.ts @@ -9,6 +9,12 @@ export interface PermissionAwareFastifyTransportOptions { cors?: boolean; logger?: boolean; app?: import("fastify").FastifyInstance; + /** + * Origins allowed to call the `/mcp` endpoints. Not set means no check; + * requests without an `Origin` header are always allowed. See + * `FastifyTransportOptions.allowedOrigins`. + */ + allowedOrigins?: readonly string[]; /** * Optional custom HTTP endpoints to register alongside MCP protocol endpoints. * Allows adding REST-like endpoints with Zod validation and type inference. diff --git a/src/server/createMcpServer.ts b/src/server/createMcpServer.ts index c319960..21cca7c 100644 --- a/src/server/createMcpServer.ts +++ b/src/server/createMcpServer.ts @@ -186,6 +186,9 @@ function buildTransport( if (options.http?.app) { builder.app(options.http.app); } + if (options.http?.allowedOrigins) { + builder.allowedOrigins(options.http.allowedOrigins); + } if (options.http?.customEndpoints) { builder.customEndpoints(options.http.customEndpoints); } diff --git a/src/server/createPermissionBasedMcpServer.ts b/src/server/createPermissionBasedMcpServer.ts index a161eda..ac3f04e 100644 --- a/src/server/createPermissionBasedMcpServer.ts +++ b/src/server/createPermissionBasedMcpServer.ts @@ -187,6 +187,9 @@ function buildPermissionTransport( if (options.http?.app) { builder.app(options.http.app); } + if (options.http?.allowedOrigins) { + builder.allowedOrigins(options.http.allowedOrigins); + } if (options.http?.customEndpoints) { builder.customEndpoints(options.http.customEndpoints); } diff --git a/tests/originValidation.test.ts b/tests/originValidation.test.ts new file mode 100644 index 0000000..7822848 --- /dev/null +++ b/tests/originValidation.test.ts @@ -0,0 +1,297 @@ +import { describe, it, expect, afterEach } from "vitest"; +import Fastify from "fastify"; +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; +import { FastifyTransport } from "../src/http/FastifyTransport.js"; +import { PermissionAwareFastifyTransport } from "../src/permissions/PermissionAwareFastifyTransport.js"; +import { createMcpServer, createPermissionBasedMcpServer } from "../src/index.js"; +import { + normalizeAllowedOrigins, + normalizeOrigin, +} from "../src/http/originValidation.js"; +import { getAvailablePort, testCatalog } from "./e2e/helpers.js"; + +const manager: any = { getStatus: () => ({ ok: true }) }; +const ALLOWED = "https://app.example.com"; + +/** Starts either transport on an injected Fastify app and returns the app. */ +async function startTransport( + kind: "standard" | "permission", + allowedOrigins: string[] | undefined, + cors = false +) { + const app = Fastify({ logger: false }); + const options = { app, allowedOrigins, cors }; + const transport = + kind === "standard" + ? new FastifyTransport(manager, () => ({} as any), options) + : new PermissionAwareFastifyTransport( + manager, + async () => ({} as any), + options + ); + await transport.start(); + return { app, transport }; +} + +describe("normalizeOrigin", () => { + it("lowercases and drops default ports and paths", () => { + expect(normalizeOrigin("HTTPS://App.Example.com:443/path")).toBe( + "https://app.example.com" + ); + expect(normalizeOrigin("http://localhost:3000")).toBe("http://localhost:3000"); + }); + + it("keeps non-http schemes comparable", () => { + expect(normalizeOrigin("vscode-webview://abc123")).toBe( + "vscode-webview://abc123" + ); + }); + + it("lower-cases the host of non-http schemes", () => { + expect(normalizeOrigin("vscode-webview://ABC")).toBe("vscode-webview://abc"); + }); + + it("rejects values that are not origins", () => { + expect(normalizeOrigin("null")).toBeUndefined(); + expect(normalizeOrigin("not a url")).toBeUndefined(); + expect(normalizeOrigin("")).toBeUndefined(); + }); +}); + +describe("normalizeAllowedOrigins", () => { + it("throws on an entry that is not an origin", () => { + expect(() => normalizeAllowedOrigins(["*"])).toThrow(/Invalid allowedOrigins/); + expect(() => normalizeAllowedOrigins(["app.example.com"])).toThrow( + /app\.example\.com/ + ); + }); + + it.each(["standard", "permission"] as const)( + "fails %s transport construction on an invalid entry", + (kind) => { + const options = { allowedOrigins: ["*"] }; + const create = () => + kind === "standard" + ? new FastifyTransport(manager, () => ({} as any), options) + : new PermissionAwareFastifyTransport( + manager, + async () => ({} as any), + options + ); + expect(create).toThrow(/Invalid allowedOrigins/); + } + ); +}); + +describe.each(["standard", "permission"] as const)( + "Origin validation on %s transport", + (kind) => { + const apps: Array<{ stop(): Promise }> = []; + afterEach(async () => { + await Promise.all(apps.splice(0).map((t) => t.stop())); + }); + + async function start(allowed: string[] | undefined, cors = false) { + const started = await startTransport(kind, allowed, cors); + apps.push(started.transport); + return started.app; + } + + const mcpRoutes = [ + { method: "POST", payload: { jsonrpc: "2.0", id: 1, method: "ping" } }, + { method: "GET", payload: undefined }, + { method: "DELETE", payload: undefined }, + ] as const; + + it.each(mcpRoutes)("rejects a disallowed Origin on $method /mcp with 403", async ({ method, payload }) => { + const app = await start([ALLOWED]); + const res = await app.inject({ + method, + url: "/mcp", + headers: { origin: "https://evil.example.com", "mcp-client-id": "c1" }, + payload, + }); + expect(res.statusCode).toBe(403); + expect(res.json().error.message).toBe("Forbidden: Origin not allowed"); + }); + + it.each(mcpRoutes)("lets $method /mcp past the check for an allowed Origin", async ({ method, payload }) => { + const app = await start([ALLOWED]); + const res = await app.inject({ + method, + url: "/mcp", + headers: { origin: "HTTPS://APP.example.com" }, + payload, + }); + // No mcp-client-id, so the handler answers 400: the guard did not block. + expect(res.statusCode).toBe(400); + }); + + it.each([ + "https://app.example.com/anything", + "https://user@app.example.com", + "https://app.example.com?x=1", + "https://app.example.com:443", + ])("rejects the non-canonical Origin %s", async (origin) => { + const app = await start([ALLOWED]); + const res = await app.inject({ + method: "POST", + url: "/mcp", + headers: { origin, "mcp-client-id": "c1" }, + payload: {}, + }); + expect(res.statusCode).toBe(403); + }); + + it("allows requests without an Origin header", async () => { + const app = await start([ALLOWED]); + const res = await app.inject({ + method: "POST", + url: "/mcp", + payload: { jsonrpc: "2.0", id: 1, method: "ping" }, + }); + expect(res.statusCode).toBe(400); + }); + + it("always rejects the literal Origin: null", async () => { + const app = await start([ALLOWED]); + const res = await app.inject({ + method: "POST", + url: "/mcp", + headers: { origin: "null", "mcp-client-id": "c1" }, + payload: {}, + }); + expect(res.statusCode).toBe(403); + }); + + it("with an empty allowlist rejects every request that has an Origin", async () => { + const app = await start([]); + const res = await app.inject({ + method: "POST", + url: "/mcp", + headers: { origin: ALLOWED, "mcp-client-id": "c1" }, + payload: {}, + }); + expect(res.statusCode).toBe(403); + }); + + it("does not check Origin when allowedOrigins is not set", async () => { + const app = await start(undefined); + const res = await app.inject({ + method: "POST", + url: "/mcp", + headers: { origin: "https://evil.example.com" }, + payload: {}, + }); + expect(res.statusCode).toBe(400); + }); + + it("leaves /healthz open to any Origin", async () => { + const app = await start([ALLOWED]); + const res = await app.inject({ + method: "GET", + url: "/healthz", + headers: { origin: "https://evil.example.com" }, + }); + expect(res.statusCode).toBe(200); + }); + + it("grants CORS only to allowed origins when cors is on", async () => { + const app = await start([ALLOWED], true); + const preflight = (origin: string) => + app.inject({ + method: "OPTIONS", + url: "/mcp", + headers: { + origin, + "access-control-request-method": "POST", + }, + }); + const allowed = await preflight(ALLOWED); + expect(allowed.headers["access-control-allow-origin"]).toBe(ALLOWED); + const denied = await preflight("https://evil.example.com"); + expect(denied.headers["access-control-allow-origin"]).toBeUndefined(); + }); + } +); + +describe("Origin validation through createMcpServer", () => { + let server: Awaited>; + let port: number; + + afterEach(async () => { + await server.close(); + }); + + async function startServer() { + port = await getAvailablePort(); + server = await createMcpServer({ + createServer: () => new McpServer({ name: "origin-test", version: "1.0.0" }), + catalog: testCatalog, + startup: { mode: "STATIC", toolsets: ["core"] }, + http: { port, allowedOrigins: [ALLOWED] }, + }); + await server.start(); + } + + function connect(origin: string | undefined) { + const headers: Record = { "mcp-client-id": "origin-client" }; + if (origin) headers.origin = origin; + const transport = new StreamableHTTPClientTransport( + new URL(`http://localhost:${port}/mcp`), + { requestInit: { headers } } + ); + const client = new Client({ name: "test", version: "1.0.0" }); + return { client, connected: client.connect(transport) }; + } + + it("serves a client that sends an allowed Origin", async () => { + await startServer(); + const { client, connected } = connect(ALLOWED); + await connected; + expect((await client.listTools()).tools.length).toBeGreaterThan(0); + await client.close(); + }); + + it("serves a client that sends no Origin", async () => { + await startServer(); + const { client, connected } = connect(undefined); + await connected; + expect((await client.listTools()).tools.length).toBeGreaterThan(0); + await client.close(); + }); + + it("refuses a client that sends another Origin", async () => { + await startServer(); + const { connected } = connect("https://evil.example.com"); + await expect(connected).rejects.toThrow(); + }); +}); + +describe("Origin validation through createPermissionBasedMcpServer", () => { + it("passes allowedOrigins to the transport", async () => { + const port = await getAvailablePort(); + const server = await createPermissionBasedMcpServer({ + createServer: () => new McpServer({ name: "origin-test", version: "1.0.0" }), + catalog: testCatalog, + permissions: { source: "headers" }, + http: { port, allowedOrigins: [ALLOWED] }, + }); + await server.start(); + try { + const post = (origin: string) => + fetch(`http://localhost:${port}/mcp`, { + method: "POST", + headers: { origin, "content-type": "application/json" }, + body: "{}", + }); + expect((await post("https://evil.example.com")).status).toBe(403); + // Allowed Origin reaches the handler, which wants an mcp-client-id. + expect((await post(ALLOWED)).status).toBe(400); + } finally { + await server.close(); + } + }); +});