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

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