Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
73 changes: 44 additions & 29 deletions packages/middleware/express/src/express.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,26 +3,47 @@ import express from 'express';

import { hostHeaderValidation, localhostHostValidation } from './middleware/hostHeaderValidation.js';

/**
* Host header validation options for DNS rebinding protection.
*
* Either skip validation entirely, or optionally provide an explicit allowlist.
*/
export type HostHeaderValidationOptions =
| {
/**
* When set to `true`, disables all automatic host header validation
* (DNS rebinding protection).
*
* Use this when the server sits behind a reverse proxy or load balancer
* that rewrites the `Host` header, or when running in an isolated network
* (e.g., containers) where DNS rebinding is not a concern.
*/
skipHostHeaderValidation: true;
allowedHosts?: never;
}
| {
skipHostHeaderValidation?: false;
/**
* 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[];
};

/**
* Options for creating an MCP Express application.
*/
export interface CreateMcpExpressAppOptions {
export type 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[];

/**
* Controls the maximum request body size for the JSON body parser.
* Passed directly to Express's `express.json({ limit })` option.
Expand All @@ -31,7 +52,7 @@ export interface CreateMcpExpressAppOptions {
* @example '1mb', '500kb', '10mb'
*/
jsonLimit?: string;
}
} & HostHeaderValidationOptions;

/**
* Creates an Express application pre-configured for MCP servers.
Expand Down Expand Up @@ -60,27 +81,21 @@ export interface CreateMcpExpressAppOptions {
* ```
*/
export function createMcpExpressApp(options: CreateMcpExpressAppOptions = {}): Express {
const { host = '127.0.0.1', allowedHosts, jsonLimit } = options;
const { host = '127.0.0.1', allowedHosts, jsonLimit, skipHostHeaderValidation } = options;

const app = express();
app.use(express.json(jsonLimit ? { limit: jsonLimit } : undefined));

// 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.'
);
if (!skipHostHeaderValidation) {
// 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());
}
}
}

Expand Down
48 changes: 10 additions & 38 deletions packages/middleware/express/test/express.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -128,55 +128,27 @@ describe('@modelcontextprotocol/express', () => {
});

test('should use allowedHosts when provided', () => {
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
const app = createMcpExpressApp({ host: '0.0.0.0', allowedHosts: ['myapp.local'] });
warn.mockRestore();

expect(app).toBeDefined();
});

test('should warn when binding to 0.0.0.0 without allowedHosts', () => {
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});

createMcpExpressApp({ host: '0.0.0.0' });

expect(warn).toHaveBeenCalledWith(
expect.stringContaining('Warning: Server is binding to 0.0.0.0 without DNS rebinding protection')
);

warn.mockRestore();
});

test('should warn when binding to :: without allowedHosts', () => {
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});

createMcpExpressApp({ host: '::' });

expect(warn).toHaveBeenCalledWith(expect.stringContaining('Warning: Server is binding to :: without DNS rebinding protection'));
test('should not apply host validation for non-localhost hosts without allowedHosts', () => {
// For arbitrary hosts (not 0.0.0.0 or ::), no validation is applied
const app = createMcpExpressApp({ host: '192.168.1.1' });

warn.mockRestore();
expect(app).toBeDefined();
});

test('should not warn for 0.0.0.0 when allowedHosts is provided', () => {
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});

createMcpExpressApp({ host: '0.0.0.0', allowedHosts: ['myapp.local'] });

expect(warn).not.toHaveBeenCalled();

warn.mockRestore();
test('should skip host header validation when skipHostHeaderValidation is true', () => {
// HTTP-level verification is in integration tests (test/integration/test/server.test.ts)
const app = createMcpExpressApp({ host: '127.0.0.1', skipHostHeaderValidation: true });
expect(app).toBeDefined();
});

test('should not apply host validation for non-localhost hosts without allowedHosts', () => {
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});

// For arbitrary hosts (not 0.0.0.0 or ::), no validation is applied and no warning
const app = createMcpExpressApp({ host: '192.168.1.1' });

expect(warn).not.toHaveBeenCalled();
test('should skip host header validation for 0.0.0.0 when skipHostHeaderValidation is true', () => {
const app = createMcpExpressApp({ host: '0.0.0.0', skipHostHeaderValidation: true });
expect(app).toBeDefined();

warn.mockRestore();
});

test('should accept jsonLimit option', () => {
Expand Down
73 changes: 44 additions & 29 deletions packages/middleware/hono/src/hono.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,26 +3,47 @@ import { Hono } from 'hono';

import { hostHeaderValidation, localhostHostValidation } from './middleware/hostHeaderValidation.js';

/**
* Host header validation options for DNS rebinding protection.
*
* Either skip validation entirely, or optionally provide an explicit allowlist.
*/
export type HostHeaderValidationOptions =
| {
/**
* When set to `true`, disables all automatic host header validation
* (DNS rebinding protection).
*
* Use this when the server sits behind a reverse proxy or load balancer
* that rewrites the `Host` header, or when running in an isolated network
* (e.g., containers) where DNS rebinding is not a concern.
*/
skipHostHeaderValidation: true;
allowedHosts?: never;
}
| {
skipHostHeaderValidation?: false;
/**
* 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[];
};

/**
* Options for creating an MCP Hono application.
*/
export interface CreateMcpHonoAppOptions {
export type CreateMcpHonoAppOptions = {
/**
* 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[];
}
} & HostHeaderValidationOptions;

/**
* Creates a Hono application pre-configured for MCP servers.
Expand All @@ -39,7 +60,7 @@ export interface CreateMcpHonoAppOptions {
* @returns A configured Hono application
*/
export function createMcpHonoApp(options: CreateMcpHonoAppOptions = {}): Hono {
const { host = '127.0.0.1', allowedHosts } = options;
const { host = '127.0.0.1', allowedHosts, skipHostHeaderValidation } = options;

const app = new Hono();

Expand Down Expand Up @@ -67,22 +88,16 @@ export function createMcpHonoApp(options: CreateMcpHonoAppOptions = {}): Hono {
return await next();
});

// 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.'
);
if (!skipHostHeaderValidation) {
// 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());
}
}
}

Expand Down
24 changes: 19 additions & 5 deletions packages/middleware/hono/test/hono.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import type { Context } from 'hono';
import { Hono } from 'hono';
import { vi } from 'vitest';

import { createMcpHonoApp } from '../src/hono.js';
import { hostHeaderValidation } from '../src/middleware/hostHeaderValidation.js';
Expand Down Expand Up @@ -40,9 +39,7 @@ describe('@modelcontextprotocol/hono', () => {
});

test('createMcpHonoApp uses allowedHosts when provided (even when binding to 0.0.0.0)', async () => {
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
const app = createMcpHonoApp({ host: '0.0.0.0', allowedHosts: ['myapp.local'] });
warn.mockRestore();

app.get('/health', c => c.text('ok'));

Expand All @@ -54,16 +51,33 @@ describe('@modelcontextprotocol/hono', () => {
});

test('createMcpHonoApp does not apply host validation for 0.0.0.0 without allowedHosts', async () => {
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
const app = createMcpHonoApp({ host: '0.0.0.0' });
warn.mockRestore();

app.get('/health', c => c.text('ok'));

const res = await app.request('http://localhost/health', { headers: { Host: 'evil.com:3000' } });
expect(res.status).toBe(200);
});

test('createMcpHonoApp skips all host validation when skipHostHeaderValidation is true', async () => {
const app = createMcpHonoApp({ host: '127.0.0.1', skipHostHeaderValidation: true });

app.get('/health', c => c.text('ok'));

// Would normally be blocked by localhost validation, but skipHostHeaderValidation disables it
const res = await app.request('http://localhost/health', { headers: { Host: 'evil.com:3000' } });
expect(res.status).toBe(200);
});

test('createMcpHonoApp skips validation for 0.0.0.0 when skipHostHeaderValidation is true', async () => {
const app = createMcpHonoApp({ host: '0.0.0.0', skipHostHeaderValidation: true });

app.get('/health', c => c.text('ok'));

const res = await app.request('http://localhost/health', { headers: { Host: 'anything.com:3000' } });
expect(res.status).toBe(200);
});

test('createMcpHonoApp parses JSON bodies into parsedBody (express.json()-like)', async () => {
const app = createMcpHonoApp();
app.post('/echo', (c: Context) => c.json(c.get('parsedBody')));
Expand Down
33 changes: 18 additions & 15 deletions test/integration/test/server.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2308,31 +2308,34 @@ describe('createMcpExpressApp', () => {
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 not apply host validation for 0.0.0.0 without allowedHosts', async () => {
const app = createMcpExpressApp({ host: '0.0.0.0' });
app.post('/test', (_req: Request, res: Response) => {
res.json({ success: true });
});

// No host validation applied, so any host should be accepted
const response = await supertest(app).post('/test').set('Host', 'anything.com:3000').send({});
expect(response.status).toBe(200);
});

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 skip host validation when skipHostHeaderValidation is true', async () => {
const app = createMcpExpressApp({ host: '127.0.0.1', skipHostHeaderValidation: true });
app.post('/test', (_req: Request, res: Response) => {
res.json({ success: true });
});

// Localhost validation would normally block this, but skipHostHeaderValidation disables it
const response = await supertest(app).post('/test').set('Host', 'evil.com:3000').send({});
expect(response.status).toBe(200);
});

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: Request, res: Response) => {
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);
Expand Down
Loading