Merge commit from fork

* Add secure default for DNS rebinding, use in examples

* Update README examples to use createMcpExpressApp, bump to 1.23.0

- Update all server examples to use createMcpExpressApp() for secure defaults
- Rewrite DNS Rebinding Protection section to document new middleware approach
- Bump version from 1.23.0-beta.0 to 1.23.0

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

* Add IPv6 support, allowedHosts option, and warning for unprotected servers

- Use native URL API to parse Host headers (handles IPv6 correctly)
- Add [::1] (IPv6 localhost) to localhostHostValidation
- Add ::1 to list of localhost hosts that get automatic protection
- Add allowedHosts option to createMcpExpressApp for custom host validation
- Warn when binding to 0.0.0.0 or :: without allowedHosts configured
- Add tests for IPv6 and allowedHosts functionality

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

* Update remaining examples to use createMcpExpressApp and add DNS rebinding docs

- Update simpleTaskInteractive.ts, ssePollingExample.ts, and elicitationUrlExample.ts
  to use createMcpExpressApp() instead of express() + express.json()
- Add DNS rebinding protection section to docs/server.md explaining how to use
  createMcpExpressApp() and hostHeaderValidation middleware

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

---------

Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
Paul Carleton 2025-12-02 13:23:06 +00:00 • committed by GitHub
commit 09623e2aa5
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 387 additions and 77 deletions

View file

@ -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();

View file

@ -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;
}

View file

@ -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]']);
}

View file

@ -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;
}

View file

@ -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;