Add DNS rebinding protection for SSE transport
This commit is contained in:
parent
590d484137
commit
adbacc6388
3 changed files with 306 additions and 4 deletions
4
package-lock.json
generated
4
package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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, {
|
||||
|
|
|
|||
Loading…
Reference in a new issue