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:
parent
cf51343cc1
commit
09623e2aa5
16 changed files with 387 additions and 77 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 } = {};
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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 } = {};
|
||||
|
|
|
|||
|
|
@ -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<string, SSEServerTransport> = {};
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 } = {};
|
||||
|
|
|
|||
|
|
@ -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<string, StreamableHTTPServerTransport | SSEServerTransport> = {};
|
||||
|
|
|
|||
|
|
@ -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<string, StreamableHTTPServerTransport>();
|
||||
|
||||
// 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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
79
src/server/middleware/hostHeaderValidation.ts
Normal file
79
src/server/middleware/hostHeaderValidation.ts
Normal 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]']);
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue