Add DNS rebinding protection for SSE transport

This commit is contained in:
David Dworken 2025-05-29 13:32:26 -07:00
commit adbacc6388
No known key found for this signature in database
3 changed files with 306 additions and 4 deletions

4
package-lock.json generated
View file

@ -1,12 +1,12 @@
{
"name": "@modelcontextprotocol/sdk",
"version": "1.11.4",
"version": "1.12.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@modelcontextprotocol/sdk",
"version": "1.11.4",
"version": "1.12.1",
"license": "MIT",
"dependencies": {
"ajv": "^6.12.6",

View file

@ -1,12 +1,14 @@
import http from 'http';
import { jest } from '@jest/globals';
import { SSEServerTransport } from './sse.js';
import { SSEServerTransport } from './sse.js';
import { AuthInfo } from './auth/types.js';
const createMockResponse = () => {
const res = {
writeHead: jest.fn<http.ServerResponse['writeHead']>(),
write: jest.fn<http.ServerResponse['write']>().mockReturnValue(true),
on: jest.fn<http.ServerResponse['on']>(),
end: jest.fn<http.ServerResponse['end']>().mockReturnThis(),
};
res.writeHead.mockReturnThis();
res.on.mockReturnThis();
@ -14,6 +16,12 @@ const createMockResponse = () => {
return res as unknown as http.ServerResponse;
};
const createMockRequest = (headers: Record<string, string> = {}) => {
return {
headers,
} as unknown as http.IncomingMessage & { auth?: AuthInfo };
};
describe('SSEServerTransport', () => {
describe('start method', () => {
it('should correctly append sessionId to a simple relative endpoint', async () => {
@ -106,4 +114,234 @@ describe('SSEServerTransport', () => {
);
});
});
describe('DNS rebinding protection', () => {
beforeEach(() => {
jest.clearAllMocks();
});
describe('Host header validation', () => {
it('should accept requests with allowed host headers', async () => {
const mockRes = createMockResponse();
const transport = new SSEServerTransport('/messages', mockRes, {
allowedHosts: ['localhost:3000', 'example.com'],
});
await transport.start();
const mockReq = createMockRequest({
host: 'localhost:3000',
'content-type': 'application/json',
});
const mockHandleRes = createMockResponse();
await transport.handlePostMessage(mockReq, mockHandleRes, { jsonrpc: '2.0', method: 'test' });
expect(mockHandleRes.writeHead).toHaveBeenCalledWith(202);
expect(mockHandleRes.end).toHaveBeenCalledWith('Accepted');
});
it('should reject requests with disallowed host headers', async () => {
const mockRes = createMockResponse();
const transport = new SSEServerTransport('/messages', mockRes, {
allowedHosts: ['localhost:3000'],
});
await transport.start();
const mockReq = createMockRequest({
host: 'evil.com',
'content-type': 'application/json',
});
const mockHandleRes = createMockResponse();
await transport.handlePostMessage(mockReq, mockHandleRes, { jsonrpc: '2.0', method: 'test' });
expect(mockHandleRes.writeHead).toHaveBeenCalledWith(403);
expect(mockHandleRes.end).toHaveBeenCalledWith('Invalid Host header: evil.com');
});
it('should reject requests without host header when allowedHosts is configured', async () => {
const mockRes = createMockResponse();
const transport = new SSEServerTransport('/messages', mockRes, {
allowedHosts: ['localhost:3000'],
});
await transport.start();
const mockReq = createMockRequest({
'content-type': 'application/json',
});
const mockHandleRes = createMockResponse();
await transport.handlePostMessage(mockReq, mockHandleRes, { jsonrpc: '2.0', method: 'test' });
expect(mockHandleRes.writeHead).toHaveBeenCalledWith(403);
expect(mockHandleRes.end).toHaveBeenCalledWith('Invalid Host header: undefined');
});
});
describe('Origin header validation', () => {
it('should accept requests with allowed origin headers', async () => {
const mockRes = createMockResponse();
const transport = new SSEServerTransport('/messages', mockRes, {
allowedOrigins: ['http://localhost:3000', 'https://example.com'],
});
await transport.start();
const mockReq = createMockRequest({
origin: 'http://localhost:3000',
'content-type': 'application/json',
});
const mockHandleRes = createMockResponse();
await transport.handlePostMessage(mockReq, mockHandleRes, { jsonrpc: '2.0', method: 'test' });
expect(mockHandleRes.writeHead).toHaveBeenCalledWith(202);
expect(mockHandleRes.end).toHaveBeenCalledWith('Accepted');
});
it('should reject requests with disallowed origin headers', async () => {
const mockRes = createMockResponse();
const transport = new SSEServerTransport('/messages', mockRes, {
allowedOrigins: ['http://localhost:3000'],
});
await transport.start();
const mockReq = createMockRequest({
origin: 'http://evil.com',
'content-type': 'application/json',
});
const mockHandleRes = createMockResponse();
await transport.handlePostMessage(mockReq, mockHandleRes, { jsonrpc: '2.0', method: 'test' });
expect(mockHandleRes.writeHead).toHaveBeenCalledWith(403);
expect(mockHandleRes.end).toHaveBeenCalledWith('Invalid Origin header: http://evil.com');
});
});
describe('Content-Type validation', () => {
it('should accept requests with application/json content-type', async () => {
const mockRes = createMockResponse();
const transport = new SSEServerTransport('/messages', mockRes);
await transport.start();
const mockReq = createMockRequest({
'content-type': 'application/json',
});
const mockHandleRes = createMockResponse();
await transport.handlePostMessage(mockReq, mockHandleRes, { jsonrpc: '2.0', method: 'test' });
expect(mockHandleRes.writeHead).toHaveBeenCalledWith(202);
expect(mockHandleRes.end).toHaveBeenCalledWith('Accepted');
});
it('should accept requests with application/json with charset', async () => {
const mockRes = createMockResponse();
const transport = new SSEServerTransport('/messages', mockRes);
await transport.start();
const mockReq = createMockRequest({
'content-type': 'application/json; charset=utf-8',
});
const mockHandleRes = createMockResponse();
await transport.handlePostMessage(mockReq, mockHandleRes, { jsonrpc: '2.0', method: 'test' });
expect(mockHandleRes.writeHead).toHaveBeenCalledWith(202);
expect(mockHandleRes.end).toHaveBeenCalledWith('Accepted');
});
it('should reject requests with non-application/json content-type when protection is enabled', async () => {
const mockRes = createMockResponse();
const transport = new SSEServerTransport('/messages', mockRes);
await transport.start();
const mockReq = createMockRequest({
'content-type': 'text/plain',
});
const mockHandleRes = createMockResponse();
await transport.handlePostMessage(mockReq, mockHandleRes, { jsonrpc: '2.0', method: 'test' });
expect(mockHandleRes.writeHead).toHaveBeenCalledWith(400);
expect(mockHandleRes.end).toHaveBeenCalledWith('Error: Content-Type must start with application/json, got: text/plain');
});
});
describe('disableDnsRebindingProtection option', () => {
it('should skip all validations when disableDnsRebindingProtection is true', async () => {
const mockRes = createMockResponse();
const transport = new SSEServerTransport('/messages', mockRes, {
allowedHosts: ['localhost:3000'],
allowedOrigins: ['http://localhost:3000'],
disableDnsRebindingProtection: true,
});
await transport.start();
const mockReq = createMockRequest({
host: 'evil.com',
origin: 'http://evil.com',
'content-type': 'text/plain',
});
const mockHandleRes = createMockResponse();
await transport.handlePostMessage(mockReq, mockHandleRes, { jsonrpc: '2.0', method: 'test' });
// Should pass even with invalid headers because protection is disabled
expect(mockHandleRes.writeHead).toHaveBeenCalledWith(400);
// The error should be from content-type parsing, not DNS rebinding protection
expect(mockHandleRes.end).toHaveBeenCalledWith('Error: Unsupported content-type: text/plain');
});
});
describe('Combined validations', () => {
it('should validate both host and origin when both are configured', async () => {
const mockRes = createMockResponse();
const transport = new SSEServerTransport('/messages', mockRes, {
allowedHosts: ['localhost:3000'],
allowedOrigins: ['http://localhost:3000'],
});
await transport.start();
// Valid host, invalid origin
const mockReq1 = createMockRequest({
host: 'localhost:3000',
origin: 'http://evil.com',
'content-type': 'application/json',
});
const mockHandleRes1 = createMockResponse();
await transport.handlePostMessage(mockReq1, mockHandleRes1, { jsonrpc: '2.0', method: 'test' });
expect(mockHandleRes1.writeHead).toHaveBeenCalledWith(403);
expect(mockHandleRes1.end).toHaveBeenCalledWith('Invalid Origin header: http://evil.com');
// Invalid host, valid origin
const mockReq2 = createMockRequest({
host: 'evil.com',
origin: 'http://localhost:3000',
'content-type': 'application/json',
});
const mockHandleRes2 = createMockResponse();
await transport.handlePostMessage(mockReq2, mockHandleRes2, { jsonrpc: '2.0', method: 'test' });
expect(mockHandleRes2.writeHead).toHaveBeenCalledWith(403);
expect(mockHandleRes2.end).toHaveBeenCalledWith('Invalid Host header: evil.com');
// Both valid
const mockReq3 = createMockRequest({
host: 'localhost:3000',
origin: 'http://localhost:3000',
'content-type': 'application/json',
});
const mockHandleRes3 = createMockResponse();
await transport.handlePostMessage(mockReq3, mockHandleRes3, { jsonrpc: '2.0', method: 'test' });
expect(mockHandleRes3.writeHead).toHaveBeenCalledWith(202);
expect(mockHandleRes3.end).toHaveBeenCalledWith('Accepted');
});
});
});
});

View file

@ -9,6 +9,29 @@ import { URL } from 'url';
const MAXIMUM_MESSAGE_SIZE = "4mb";
/**
* Configuration options for SSEServerTransport.
*/
export interface SSEServerTransportOptions {
/**
* List of allowed host header values for DNS rebinding protection.
* If not specified, host validation is disabled.
*/
allowedHosts?: string[];
/**
* List of allowed origin header values for DNS rebinding protection.
* If not specified, origin validation is disabled.
*/
allowedOrigins?: string[];
/**
* Disable DNS rebinding protection entirely (overrides allowedHosts and allowedOrigins).
* Default is false.
*/
disableDnsRebindingProtection?: boolean;
}
/**
* Server transport for SSE: this will send messages over an SSE connection and receive messages from HTTP POST requests.
*
@ -17,6 +40,7 @@ const MAXIMUM_MESSAGE_SIZE = "4mb";
export class SSEServerTransport implements Transport {
private _sseResponse?: ServerResponse;
private _sessionId: string;
private _options: SSEServerTransportOptions;
onclose?: () => void;
onerror?: (error: Error) => void;
@ -28,8 +52,39 @@ export class SSEServerTransport implements Transport {
constructor(
private _endpoint: string,
private res: ServerResponse,
options?: SSEServerTransportOptions,
) {
this._sessionId = randomUUID();
this._options = options || {disableDnsRebindingProtection: true};
}
/**
* Validates request headers for DNS rebinding protection.
* @returns Error message if validation fails, undefined if validation passes.
*/
private validateRequestHeaders(req: IncomingMessage): string | undefined {
// Skip validation if protection is disabled
if (this._options.disableDnsRebindingProtection) {
return undefined;
}
// Validate Host header if allowedHosts is configured
if (this._options.allowedHosts && this._options.allowedHosts.length > 0) {
const hostHeader = req.headers.host;
if (!hostHeader || !this._options.allowedHosts.includes(hostHeader)) {
return `Invalid Host header: ${hostHeader}`;
}
}
// Validate Origin header if allowedOrigins is configured
if (this._options.allowedOrigins && this._options.allowedOrigins.length > 0) {
const originHeader = req.headers.origin;
if (!originHeader || !this._options.allowedOrigins.includes(originHeader)) {
return `Invalid Origin header: ${originHeader}`;
}
}
return undefined;
}
/**
@ -86,13 +141,22 @@ export class SSEServerTransport implements Transport {
res.writeHead(500).end(message);
throw new Error(message);
}
// Validate request headers for DNS rebinding protection
const validationError = this.validateRequestHeaders(req);
if (validationError) {
res.writeHead(403).end(validationError);
this.onerror?.(new Error(validationError));
return;
}
const authInfo: AuthInfo | undefined = req.auth;
let body: string | unknown;
try {
const ct = contentType.parse(req.headers["content-type"] ?? "");
if (ct.type !== "application/json") {
throw new Error(`Unsupported content-type: ${ct}`);
throw new Error(`Unsupported content-type: ${ct.type}`);
}
body = parsedBody ?? await getRawBody(req, {