diff --git a/docs/server.md b/docs/server.md index 8c42f31a..b319e6f0 100644 --- a/docs/server.md +++ b/docs/server.md @@ -66,6 +66,34 @@ For a minimal “getting started” experience: For more detailed patterns (stateless vs stateful, JSON response mode, CORS, DNS rebind protection), see the examples above and the MCP spec sections on transports. +## DNS rebinding protection + +MCP servers running on localhost are vulnerable to DNS rebinding attacks. Use `createMcpExpressApp()` to create an Express app with DNS rebinding protection enabled by default: + +```typescript +import { createMcpExpressApp } from '@modelcontextprotocol/sdk/server/index.js'; + +// Protection auto-enabled (default host is 127.0.0.1) +const app = createMcpExpressApp(); + +// Protection auto-enabled for localhost +const app = createMcpExpressApp({ host: 'localhost' }); + +// No auto protection when binding to all interfaces +const app = createMcpExpressApp({ host: '0.0.0.0' }); +``` + +For custom host validation, use the middleware directly: + +```typescript +import express from 'express'; +import { hostHeaderValidation } from '@modelcontextprotocol/sdk/server/middleware/hostHeaderValidation.js'; + +const app = express(); +app.use(express.json()); +app.use(hostHeaderValidation(['localhost', '127.0.0.1', 'myhost.local'])); +``` + ## Tools, resources, and prompts ### Tools diff --git a/src/examples/server/elicitationFormExample.ts b/src/examples/server/elicitationFormExample.ts index 2e6286b7..e3ce083d 100644 --- a/src/examples/server/elicitationFormExample.ts +++ b/src/examples/server/elicitationFormExample.ts @@ -8,11 +8,11 @@ // to collect *sensitive* user input via a browser. import { randomUUID } from 'node:crypto'; -import cors from 'cors'; -import express, { type Request, type Response } from 'express'; +import { type Request, type Response } from 'express'; import { McpServer } from '../../server/mcp.js'; import { StreamableHTTPServerTransport } from '../../server/streamableHttp.js'; import { isInitializeRequest } from '../../types.js'; +import { createMcpExpressApp } from '../../server/index.js'; // Create MCP server - it will automatically use AjvJsonSchemaValidator with sensible defaults // The validator supports format validation (email, date, etc.) if ajv-formats is installed @@ -320,16 +320,7 @@ mcpServer.registerTool( async function main() { const PORT = process.env.PORT ? parseInt(process.env.PORT, 10) : 3000; - const app = express(); - app.use(express.json()); - - // Allow CORS for all domains, expose the Mcp-Session-Id header - app.use( - cors({ - origin: '*', - exposedHeaders: ['Mcp-Session-Id'] - }) - ); + const app = createMcpExpressApp(); // Map to store transports by session ID const transports: { [sessionId: string]: StreamableHTTPServerTransport } = {}; diff --git a/src/examples/server/elicitationUrlExample.ts b/src/examples/server/elicitationUrlExample.ts index af014fdc..e4d3d226 100644 --- a/src/examples/server/elicitationUrlExample.ts +++ b/src/examples/server/elicitationUrlExample.ts @@ -11,6 +11,7 @@ import express, { Request, Response } from 'express'; import { randomUUID } from 'node:crypto'; import { z } from 'zod'; import { McpServer } from '../../server/mcp.js'; +import { createMcpExpressApp } from '../../server/index.js'; import { StreamableHTTPServerTransport } from '../../server/streamableHttp.js'; import { getOAuthProtectedResourceMetadataUrl, mcpAuthMetadataRouter } from '../../server/auth/router.js'; import { requireBearerAuth } from '../../server/auth/middleware/bearerAuth.js'; @@ -214,8 +215,7 @@ function completeURLElicitation(elicitationId: string) { const MCP_PORT = process.env.MCP_PORT ? parseInt(process.env.MCP_PORT, 10) : 3000; const AUTH_PORT = process.env.MCP_AUTH_PORT ? parseInt(process.env.MCP_AUTH_PORT, 10) : 3001; -const app = express(); -app.use(express.json()); +const app = createMcpExpressApp(); // Allow CORS all domains, expose the Mcp-Session-Id header app.use( diff --git a/src/examples/server/jsonResponseStreamableHttp.ts b/src/examples/server/jsonResponseStreamableHttp.ts index c1206d8c..9be3d720 100644 --- a/src/examples/server/jsonResponseStreamableHttp.ts +++ b/src/examples/server/jsonResponseStreamableHttp.ts @@ -1,10 +1,10 @@ -import express, { Request, Response } from 'express'; +import { Request, Response } from 'express'; import { randomUUID } from 'node:crypto'; import { McpServer } from '../../server/mcp.js'; import { StreamableHTTPServerTransport } from '../../server/streamableHttp.js'; import * as z from 'zod/v4'; import { CallToolResult, isInitializeRequest } from '../../types.js'; -import cors from 'cors'; +import { createMcpExpressApp } from '../../server/index.js'; // Create an MCP server with implementation details const getServer = () => { @@ -90,16 +90,7 @@ const getServer = () => { return server; }; -const app = express(); -app.use(express.json()); - -// Configure CORS to expose Mcp-Session-Id header for browser-based clients -app.use( - cors({ - origin: '*', // Allow all origins - adjust as needed for production - exposedHeaders: ['Mcp-Session-Id'] - }) -); +const app = createMcpExpressApp(); // Map to store transports by session ID const transports: { [sessionId: string]: StreamableHTTPServerTransport } = {}; diff --git a/src/examples/server/simpleSseServer.ts b/src/examples/server/simpleSseServer.ts index e07f3601..bc6fd2ca 100644 --- a/src/examples/server/simpleSseServer.ts +++ b/src/examples/server/simpleSseServer.ts @@ -1,8 +1,9 @@ -import express, { Request, Response } from 'express'; +import { Request, Response } from 'express'; import { McpServer } from '../../server/mcp.js'; import { SSEServerTransport } from '../../server/sse.js'; import * as z from 'zod/v4'; import { CallToolResult } from '../../types.js'; +import { createMcpExpressApp } from '../../server/index.js'; /** * This example server demonstrates the deprecated HTTP+SSE transport @@ -75,8 +76,7 @@ const getServer = () => { return server; }; -const app = express(); -app.use(express.json()); +const app = createMcpExpressApp(); // Store transports by session ID const transports: Record = {}; diff --git a/src/examples/server/simpleStatelessStreamableHttp.ts b/src/examples/server/simpleStatelessStreamableHttp.ts index 464ea262..e2cefffd 100644 --- a/src/examples/server/simpleStatelessStreamableHttp.ts +++ b/src/examples/server/simpleStatelessStreamableHttp.ts @@ -1,9 +1,9 @@ -import express, { Request, Response } from 'express'; +import { Request, Response } from 'express'; import { McpServer } from '../../server/mcp.js'; import { StreamableHTTPServerTransport } from '../../server/streamableHttp.js'; import * as z from 'zod/v4'; import { CallToolResult, GetPromptResult, ReadResourceResult } from '../../types.js'; -import cors from 'cors'; +import { createMcpExpressApp } from '../../server/index.js'; const getServer = () => { // Create an MCP server with implementation details @@ -96,16 +96,7 @@ const getServer = () => { return server; }; -const app = express(); -app.use(express.json()); - -// Configure CORS to expose Mcp-Session-Id header for browser-based clients -app.use( - cors({ - origin: '*', // Allow all origins - adjust as needed for production - exposedHeaders: ['Mcp-Session-Id'] - }) -); +const app = createMcpExpressApp(); app.post('/mcp', async (req: Request, res: Response) => { const server = getServer(); diff --git a/src/examples/server/simpleStreamableHttp.ts b/src/examples/server/simpleStreamableHttp.ts index 9d3afda9..3500ac06 100644 --- a/src/examples/server/simpleStreamableHttp.ts +++ b/src/examples/server/simpleStreamableHttp.ts @@ -1,10 +1,11 @@ -import express, { Request, Response } from 'express'; +import { Request, Response } from 'express'; import { randomUUID } from 'node:crypto'; import * as z from 'zod/v4'; import { McpServer } from '../../server/mcp.js'; import { StreamableHTTPServerTransport } from '../../server/streamableHttp.js'; import { getOAuthProtectedResourceMetadataUrl, mcpAuthMetadataRouter } from '../../server/auth/router.js'; import { requireBearerAuth } from '../../server/auth/middleware/bearerAuth.js'; +import { createMcpExpressApp } from '../../server/index.js'; import { CallToolResult, ElicitResultSchema, @@ -20,8 +21,6 @@ import { setupAuthServer } from './demoInMemoryOAuthProvider.js'; import { OAuthMetadata } from '../../shared/auth.js'; import { checkResourceAllowed } from '../../shared/auth-utils.js'; -import cors from 'cors'; - // Check for OAuth flag const useOAuth = process.argv.includes('--oauth'); const strictOAuth = process.argv.includes('--oauth-strict'); @@ -507,16 +506,7 @@ const getServer = () => { const MCP_PORT = process.env.MCP_PORT ? parseInt(process.env.MCP_PORT, 10) : 3000; const AUTH_PORT = process.env.MCP_AUTH_PORT ? parseInt(process.env.MCP_AUTH_PORT, 10) : 3001; -const app = express(); -app.use(express.json()); - -// Allow CORS all domains, expose the Mcp-Session-Id header -app.use( - cors({ - origin: '*', // Allow all origins - exposedHeaders: ['Mcp-Session-Id'] - }) -); +const app = createMcpExpressApp(); // Set up OAuth if enabled let authMiddleware = null; diff --git a/src/examples/server/simpleTaskInteractive.ts b/src/examples/server/simpleTaskInteractive.ts index 3e0e9064..51e97b7e 100644 --- a/src/examples/server/simpleTaskInteractive.ts +++ b/src/examples/server/simpleTaskInteractive.ts @@ -9,9 +9,9 @@ * creates a task, and the result is fetched via tasks/result endpoint. */ -import express, { Request, Response } from 'express'; +import { Request, Response } from 'express'; import { randomUUID } from 'node:crypto'; -import { Server } from '../../server/index.js'; +import { createMcpExpressApp, Server } from '../../server/index.js'; import { StreamableHTTPServerTransport } from '../../server/streamableHttp.js'; import { CallToolResult, @@ -630,8 +630,7 @@ const createServer = (): Server => { // Express App Setup // ============================================================================ -const app = express(); -app.use(express.json()); +const app = createMcpExpressApp(); // Map to store transports by session ID const transports: { [sessionId: string]: StreamableHTTPServerTransport } = {}; diff --git a/src/examples/server/sseAndStreamableHttpCompatibleServer.ts b/src/examples/server/sseAndStreamableHttpCompatibleServer.ts index 8eb3724c..317cb2bf 100644 --- a/src/examples/server/sseAndStreamableHttpCompatibleServer.ts +++ b/src/examples/server/sseAndStreamableHttpCompatibleServer.ts @@ -1,4 +1,4 @@ -import express, { Request, Response } from 'express'; +import { Request, Response } from 'express'; import { randomUUID } from 'node:crypto'; import { McpServer } from '../../server/mcp.js'; import { StreamableHTTPServerTransport } from '../../server/streamableHttp.js'; @@ -6,7 +6,7 @@ import { SSEServerTransport } from '../../server/sse.js'; import * as z from 'zod/v4'; import { CallToolResult, isInitializeRequest } from '../../types.js'; import { InMemoryEventStore } from '../shared/inMemoryEventStore.js'; -import cors from 'cors'; +import { createMcpExpressApp } from '../../server/index.js'; /** * This example server demonstrates backwards compatibility with both: @@ -71,16 +71,7 @@ const getServer = () => { }; // Create Express application -const app = express(); -app.use(express.json()); - -// Configure CORS to expose Mcp-Session-Id header for browser-based clients -app.use( - cors({ - origin: '*', // Allow all origins - adjust as needed for production - exposedHeaders: ['Mcp-Session-Id'] - }) -); +const app = createMcpExpressApp(); // Store transports by session ID const transports: Record = {}; diff --git a/src/examples/server/ssePollingExample.ts b/src/examples/server/ssePollingExample.ts index ea1d752f..83ef8e4b 100644 --- a/src/examples/server/ssePollingExample.ts +++ b/src/examples/server/ssePollingExample.ts @@ -12,9 +12,10 @@ * Run with: npx tsx src/examples/server/ssePollingExample.ts * Test with: curl or the MCP Inspector */ -import express, { Request, Response } from 'express'; +import { Request, Response } from 'express'; import { randomUUID } from 'node:crypto'; import { McpServer } from '../../server/mcp.js'; +import { createMcpExpressApp } from '../../server/index.js'; import { StreamableHTTPServerTransport } from '../../server/streamableHttp.js'; import { CallToolResult } from '../../types.js'; import { InMemoryEventStore } from '../shared/inMemoryEventStore.js'; @@ -103,7 +104,7 @@ server.tool( ); // Set up Express app -const app = express(); +const app = createMcpExpressApp(); app.use(cors()); // Create event store for resumability @@ -112,8 +113,8 @@ const eventStore = new InMemoryEventStore(); // Track transports by session ID for session reuse const transports = new Map(); -// Handle all MCP requests - use express.json() only for this route -app.all('/mcp', express.json(), async (req: Request, res: Response) => { +// Handle all MCP requests +app.all('/mcp', async (req: Request, res: Response) => { const sessionId = req.headers['mcp-session-id'] as string | undefined; // Reuse existing transport or create new one diff --git a/src/examples/server/standaloneSseWithGetStreamableHttp.ts b/src/examples/server/standaloneSseWithGetStreamableHttp.ts index 6229c53a..33bd73d0 100644 --- a/src/examples/server/standaloneSseWithGetStreamableHttp.ts +++ b/src/examples/server/standaloneSseWithGetStreamableHttp.ts @@ -1,8 +1,9 @@ -import express, { Request, Response } from 'express'; +import { Request, Response } from 'express'; import { randomUUID } from 'node:crypto'; import { McpServer } from '../../server/mcp.js'; import { StreamableHTTPServerTransport } from '../../server/streamableHttp.js'; import { isInitializeRequest, ReadResourceResult } from '../../types.js'; +import { createMcpExpressApp } from '../../server/index.js'; // Create an MCP server with implementation details const server = new McpServer({ @@ -34,8 +35,7 @@ const resourceChangeInterval = setInterval(() => { addResource(name, `Content for ${name}`); }, 5000); // Change resources every 5 seconds for testing -const app = express(); -app.use(express.json()); +const app = createMcpExpressApp(); app.post('/mcp', async (req: Request, res: Response) => { console.log('Received MCP request:', req.body); diff --git a/src/server/index.test.ts b/src/server/index.test.ts index c2a56420..c01e638d 100644 --- a/src/server/index.test.ts +++ b/src/server/index.test.ts @@ -1,7 +1,9 @@ /* eslint-disable @typescript-eslint/no-unused-vars */ +import supertest from 'supertest'; import { Client } from '../client/index.js'; import { InMemoryTransport } from '../inMemory.js'; import type { Transport } from '../shared/transport.js'; +import { createMcpExpressApp } from './index.js'; import { CreateMessageRequestSchema, CreateMessageResultSchema, @@ -2057,6 +2059,167 @@ test('should respect log level for transport with sessionId', async () => { expect(clientTransport.onmessage).toHaveBeenCalled(); }); +describe('createMcpExpressApp', () => { + test('should create an Express app', () => { + const app = createMcpExpressApp(); + expect(app).toBeDefined(); + }); + + test('should parse JSON bodies', async () => { + const app = createMcpExpressApp({ host: '0.0.0.0' }); // Disable host validation for this test + app.post('/test', (req, res) => { + res.json({ received: req.body }); + }); + + const response = await supertest(app).post('/test').send({ hello: 'world' }).set('Content-Type', 'application/json'); + + expect(response.status).toBe(200); + expect(response.body).toEqual({ received: { hello: 'world' } }); + }); + + test('should reject requests with invalid Host header by default', async () => { + const app = createMcpExpressApp(); + app.post('/test', (_req, res) => { + res.json({ success: true }); + }); + + const response = await supertest(app).post('/test').set('Host', 'evil.com:3000').send({}); + + expect(response.status).toBe(403); + expect(response.body).toEqual({ + jsonrpc: '2.0', + error: { + code: -32000, + message: 'Invalid Host: evil.com' + }, + id: null + }); + }); + + test('should allow requests with localhost Host header', async () => { + const app = createMcpExpressApp(); + app.post('/test', (_req, res) => { + res.json({ success: true }); + }); + + const response = await supertest(app).post('/test').set('Host', 'localhost:3000').send({}); + + expect(response.status).toBe(200); + expect(response.body).toEqual({ success: true }); + }); + + test('should allow requests with 127.0.0.1 Host header', async () => { + const app = createMcpExpressApp(); + app.post('/test', (_req, res) => { + res.json({ success: true }); + }); + + const response = await supertest(app).post('/test').set('Host', '127.0.0.1:3000').send({}); + + expect(response.status).toBe(200); + expect(response.body).toEqual({ success: true }); + }); + + test('should not apply host validation when host is 0.0.0.0', async () => { + const app = createMcpExpressApp({ host: '0.0.0.0' }); + app.post('/test', (_req, res) => { + res.json({ success: true }); + }); + + // Should allow any host when bound to 0.0.0.0 + const response = await supertest(app).post('/test').set('Host', 'any-host.com:3000').send({}); + + expect(response.status).toBe(200); + expect(response.body).toEqual({ success: true }); + }); + + test('should apply host validation when host is explicitly localhost', async () => { + const app = createMcpExpressApp({ host: 'localhost' }); + app.post('/test', (_req, res) => { + res.json({ success: true }); + }); + + // Should reject non-localhost hosts + const response = await supertest(app).post('/test').set('Host', 'evil.com:3000').send({}); + + expect(response.status).toBe(403); + }); + + test('should allow requests with IPv6 localhost Host header', async () => { + const app = createMcpExpressApp(); + app.post('/test', (_req, res) => { + res.json({ success: true }); + }); + + const response = await supertest(app).post('/test').set('Host', '[::1]:3000').send({}); + + expect(response.status).toBe(200); + expect(response.body).toEqual({ success: true }); + }); + + test('should apply host validation when host is ::1 (IPv6 localhost)', async () => { + const app = createMcpExpressApp({ host: '::1' }); + app.post('/test', (_req, res) => { + res.json({ success: true }); + }); + + // Should reject non-localhost hosts + const response = await supertest(app).post('/test').set('Host', 'evil.com:3000').send({}); + + expect(response.status).toBe(403); + }); + + test('should warn when binding to 0.0.0.0', () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + createMcpExpressApp({ host: '0.0.0.0' }); + expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('0.0.0.0')); + warnSpy.mockRestore(); + }); + + test('should warn when binding to :: (IPv6 all interfaces)', () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + createMcpExpressApp({ host: '::' }); + expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('::')); + warnSpy.mockRestore(); + }); + + test('should use custom allowedHosts when provided', async () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const app = createMcpExpressApp({ host: '0.0.0.0', allowedHosts: ['myapp.local', 'localhost'] }); + app.post('/test', (_req, res) => { + res.json({ success: true }); + }); + + // Should not warn when allowedHosts is provided + expect(warnSpy).not.toHaveBeenCalled(); + warnSpy.mockRestore(); + + // Should allow myapp.local + const allowedResponse = await supertest(app).post('/test').set('Host', 'myapp.local:3000').send({}); + expect(allowedResponse.status).toBe(200); + + // Should reject other hosts + const rejectedResponse = await supertest(app).post('/test').set('Host', 'evil.com:3000').send({}); + expect(rejectedResponse.status).toBe(403); + }); + + test('should override default localhost validation when allowedHosts is provided', async () => { + // Even though host is localhost, we're using custom allowedHosts + const app = createMcpExpressApp({ host: 'localhost', allowedHosts: ['custom.local'] }); + app.post('/test', (_req, res) => { + res.json({ success: true }); + }); + + // Should reject localhost since it's not in allowedHosts + const response = await supertest(app).post('/test').set('Host', 'localhost:3000').send({}); + expect(response.status).toBe(403); + + // Should allow custom.local + const allowedResponse = await supertest(app).post('/test').set('Host', 'custom.local:3000').send({}); + expect(allowedResponse.status).toBe(200); + }); +}); + describe('Task-based execution', () => { test('server with TaskStore should handle task-based tool execution', async () => { const taskStore = new InMemoryTaskStore(); diff --git a/src/server/index.ts b/src/server/index.ts index aa1a62d0..43aca3b9 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -1,4 +1,6 @@ +import express, { Express } from 'express'; import { mergeCapabilities, Protocol, type NotificationOptions, type ProtocolOptions, type RequestOptions } from '../shared/protocol.js'; +import { hostHeaderValidation, localhostHostValidation } from './middleware/hostHeaderValidation.js'; import { type ClientCapabilities, type CreateMessageRequest, @@ -667,3 +669,75 @@ export class Server< return this.notification({ method: 'notifications/prompts/list_changed' }); } } + +/** + * Options for creating an MCP Express application. + */ +export interface CreateMcpExpressAppOptions { + /** + * The hostname to bind to. Defaults to '127.0.0.1'. + * When set to '127.0.0.1', 'localhost', or '::1', DNS rebinding protection is automatically enabled. + */ + host?: string; + + /** + * List of allowed hostnames for DNS rebinding protection. + * If provided, host header validation will be applied using this list. + * For IPv6, provide addresses with brackets (e.g., '[::1]'). + * + * This is useful when binding to '0.0.0.0' or '::' but still wanting + * to restrict which hostnames are allowed. + */ + allowedHosts?: string[]; +} + +/** + * Creates an Express application pre-configured for MCP servers. + * + * When the host is '127.0.0.1', 'localhost', or '::1' (the default is '127.0.0.1'), + * DNS rebinding protection middleware is automatically applied to protect against + * DNS rebinding attacks on localhost servers. + * + * @param options - Configuration options + * @returns A configured Express application + * + * @example + * ```typescript + * // Basic usage - defaults to 127.0.0.1 with DNS rebinding protection + * const app = createMcpExpressApp(); + * + * // Custom host - DNS rebinding protection only applied for localhost hosts + * const app = createMcpExpressApp({ host: '0.0.0.0' }); // No automatic DNS rebinding protection + * const app = createMcpExpressApp({ host: 'localhost' }); // DNS rebinding protection enabled + * + * // Custom allowed hosts for non-localhost binding + * const app = createMcpExpressApp({ host: '0.0.0.0', allowedHosts: ['myapp.local', 'localhost'] }); + * ``` + */ +export function createMcpExpressApp(options: CreateMcpExpressAppOptions = {}): Express { + const { host = '127.0.0.1', allowedHosts } = options; + + const app = express(); + app.use(express.json()); + + // If allowedHosts is explicitly provided, use that for validation + if (allowedHosts) { + app.use(hostHeaderValidation(allowedHosts)); + } else { + // Apply DNS rebinding protection automatically for localhost hosts + const localhostHosts = ['127.0.0.1', 'localhost', '::1']; + if (localhostHosts.includes(host)) { + app.use(localhostHostValidation()); + } else if (host === '0.0.0.0' || host === '::') { + // Warn when binding to all interfaces without DNS rebinding protection + // eslint-disable-next-line no-console + console.warn( + `Warning: Server is binding to ${host} without DNS rebinding protection. ` + + 'Consider using the allowedHosts option to restrict allowed hosts, ' + + 'or use authentication to protect your server.' + ); + } + } + + return app; +} diff --git a/src/server/middleware/hostHeaderValidation.ts b/src/server/middleware/hostHeaderValidation.ts new file mode 100644 index 00000000..16500363 --- /dev/null +++ b/src/server/middleware/hostHeaderValidation.ts @@ -0,0 +1,79 @@ +import { Request, Response, NextFunction, RequestHandler } from 'express'; + +/** + * Express middleware for DNS rebinding protection. + * Validates Host header hostname (port-agnostic) against an allowed list. + * + * This is particularly important for servers without authorization or HTTPS, + * such as localhost servers or development servers. DNS rebinding attacks can + * bypass same-origin policy by manipulating DNS to point a domain to a + * localhost address, allowing malicious websites to access your local server. + * + * @param allowedHostnames - List of allowed hostnames (without ports). + * For IPv6, provide the address with brackets (e.g., '[::1]'). + * @returns Express middleware function + * + * @example + * ```typescript + * const middleware = hostHeaderValidation(['localhost', '127.0.0.1', '[::1]']); + * app.use(middleware); + * ``` + */ +export function hostHeaderValidation(allowedHostnames: string[]): RequestHandler { + return (req: Request, res: Response, next: NextFunction) => { + const hostHeader = req.headers.host; + if (!hostHeader) { + res.status(403).json({ + jsonrpc: '2.0', + error: { + code: -32000, + message: 'Missing Host header' + }, + id: null + }); + return; + } + + // Use URL API to parse hostname (handles IPv4, IPv6, and regular hostnames) + let hostname: string; + try { + hostname = new URL(`http://${hostHeader}`).hostname; + } catch { + res.status(403).json({ + jsonrpc: '2.0', + error: { + code: -32000, + message: `Invalid Host header: ${hostHeader}` + }, + id: null + }); + return; + } + + if (!allowedHostnames.includes(hostname)) { + res.status(403).json({ + jsonrpc: '2.0', + error: { + code: -32000, + message: `Invalid Host: ${hostname}` + }, + id: null + }); + return; + } + next(); + }; +} + +/** + * Convenience middleware for localhost DNS rebinding protection. + * Allows only localhost, 127.0.0.1, and [::1] (IPv6 localhost) hostnames. + * + * @example + * ```typescript + * app.use(localhostHostValidation()); + * ``` + */ +export function localhostHostValidation(): RequestHandler { + return hostHeaderValidation(['localhost', '127.0.0.1', '[::1]']); +} diff --git a/src/server/sse.ts b/src/server/sse.ts index abe969b3..270eebc1 100644 --- a/src/server/sse.ts +++ b/src/server/sse.ts @@ -16,18 +16,24 @@ export interface SSEServerTransportOptions { /** * List of allowed host header values for DNS rebinding protection. * If not specified, host validation is disabled. + * @deprecated Use the `hostHeaderValidation` middleware from `@modelcontextprotocol/sdk/server/middleware/hostHeaderValidation.js` instead, + * or use `createMcpExpressApp` from `@modelcontextprotocol/sdk/server/index.js` which includes localhost protection by default. */ allowedHosts?: string[]; /** * List of allowed origin header values for DNS rebinding protection. * If not specified, origin validation is disabled. + * @deprecated Use the `hostHeaderValidation` middleware from `@modelcontextprotocol/sdk/server/middleware/hostHeaderValidation.js` instead, + * or use `createMcpExpressApp` from `@modelcontextprotocol/sdk/server/index.js` which includes localhost protection by default. */ allowedOrigins?: string[]; /** * Enable DNS rebinding protection (requires allowedHosts and/or allowedOrigins to be configured). * Default is false for backwards compatibility. + * @deprecated Use the `hostHeaderValidation` middleware from `@modelcontextprotocol/sdk/server/middleware/hostHeaderValidation.js` instead, + * or use `createMcpExpressApp` from `@modelcontextprotocol/sdk/server/index.js` which includes localhost protection by default. */ enableDnsRebindingProtection?: boolean; } diff --git a/src/server/streamableHttp.ts b/src/server/streamableHttp.ts index 0473c464..841d6654 100644 --- a/src/server/streamableHttp.ts +++ b/src/server/streamableHttp.ts @@ -104,18 +104,24 @@ export interface StreamableHTTPServerTransportOptions { /** * List of allowed host header values for DNS rebinding protection. * If not specified, host validation is disabled. + * @deprecated Use the `hostHeaderValidation` middleware from `@modelcontextprotocol/sdk/server/middleware/hostHeaderValidation.js` instead, + * or use `createMcpExpressApp` from `@modelcontextprotocol/sdk/server/index.js` which includes localhost protection by default. */ allowedHosts?: string[]; /** * List of allowed origin header values for DNS rebinding protection. * If not specified, origin validation is disabled. + * @deprecated Use the `hostHeaderValidation` middleware from `@modelcontextprotocol/sdk/server/middleware/hostHeaderValidation.js` instead, + * or use `createMcpExpressApp` from `@modelcontextprotocol/sdk/server/index.js` which includes localhost protection by default. */ allowedOrigins?: string[]; /** * Enable DNS rebinding protection (requires allowedHosts and/or allowedOrigins to be configured). * Default is false for backwards compatibility. + * @deprecated Use the `hostHeaderValidation` middleware from `@modelcontextprotocol/sdk/server/middleware/hostHeaderValidation.js` instead, + * or use `createMcpExpressApp` from `@modelcontextprotocol/sdk/server/index.js` which includes localhost protection by default. */ enableDnsRebindingProtection?: boolean;