Add protections for streamable HTTP too

This commit is contained in:
David Dworken 2025-05-29 13:52:30 -07:00
commit ebf2535b90
No known key found for this signature in database
3 changed files with 351 additions and 2 deletions

View file

@ -251,7 +251,11 @@ app.post('/mcp', async (req, res) => {
onsessioninitialized: (sessionId) => {
// Store the transport by session ID
transports[sessionId] = transport;
}
},
// DNS rebinding protection is disabled by default for backwards compatibility. If you are running this server
// locally, make sure to set:
// disableDnsRebindingProtection: true,
// allowedHosts: ['127.0.0.1'],
});
// Clean up transport when closed
@ -386,6 +390,22 @@ This stateless approach is useful for:
- RESTful scenarios where each request is independent
- Horizontally scaled deployments without shared session state
#### DNS Rebinding Protection
The Streamable HTTP transport includes DNS rebinding protection to prevent security vulnerabilities. By default, this protection is **disabled** for backwards compatibility.
**Important**: If you are running this server locally, enable DNS rebinding protection:
```typescript
const transport = new StreamableHTTPServerTransport({
sessionIdGenerator: () => randomUUID(),
disableDnsRebindingProtection: false,
allowedHosts: ['127.0.0.1', ...],
allowedOrigins: ['https://yourdomain.com', 'https://www.yourdomain.com']
});
```
### Testing and Debugging
To test your server, you can use the [MCP Inspector](https://github.com/modelcontextprotocol/inspector). See its README for more information.

View file

@ -1293,4 +1293,265 @@ describe("StreamableHTTPServerTransport in stateless mode", () => {
});
expect(stream2.status).toBe(409); // Conflict - only one stream allowed
});
});
});
// Test DNS rebinding protection
describe("StreamableHTTPServerTransport DNS rebinding protection", () => {
let server: Server;
let transport: StreamableHTTPServerTransport;
let baseUrl: URL;
afterEach(async () => {
if (server && transport) {
await stopTestServer({ server, transport });
}
});
describe("Host header validation", () => {
it("should accept requests with allowed host headers", async () => {
const result = await createTestServerWithDnsProtection({
sessionIdGenerator: undefined,
allowedHosts: ['localhost:3001'],
disableDnsRebindingProtection: false,
});
server = result.server;
transport = result.transport;
baseUrl = result.baseUrl;
// Note: fetch() automatically sets Host header to match the URL
// Since we're connecting to localhost:3001 and that's in allowedHosts, this should work
const response = await fetch(baseUrl, {
method: "POST",
headers: {
"Content-Type": "application/json",
Accept: "application/json, text/event-stream",
},
body: JSON.stringify(TEST_MESSAGES.initialize),
});
expect(response.status).toBe(200);
});
it("should reject requests with disallowed host headers", async () => {
// Test DNS rebinding protection by creating a server that only allows example.com
// but we're connecting via localhost, so it should be rejected
const result = await createTestServerWithDnsProtection({
sessionIdGenerator: undefined,
allowedHosts: ['example.com:3001'],
disableDnsRebindingProtection: false,
});
server = result.server;
transport = result.transport;
baseUrl = result.baseUrl;
const response = await fetch(baseUrl, {
method: "POST",
headers: {
"Content-Type": "application/json",
Accept: "application/json, text/event-stream",
},
body: JSON.stringify(TEST_MESSAGES.initialize),
});
expect(response.status).toBe(403);
const body = await response.json();
expect(body.error.message).toContain("Invalid Host header:");
});
it("should reject GET requests with disallowed host headers", async () => {
const result = await createTestServerWithDnsProtection({
sessionIdGenerator: undefined,
allowedHosts: ['example.com:3001'],
disableDnsRebindingProtection: false,
});
server = result.server;
transport = result.transport;
baseUrl = result.baseUrl;
const response = await fetch(baseUrl, {
method: "GET",
headers: {
Accept: "text/event-stream",
},
});
expect(response.status).toBe(403);
});
});
describe("Origin header validation", () => {
it("should accept requests with allowed origin headers", async () => {
const result = await createTestServerWithDnsProtection({
sessionIdGenerator: undefined,
allowedOrigins: ['http://localhost:3000', 'https://example.com'],
disableDnsRebindingProtection: false,
});
server = result.server;
transport = result.transport;
baseUrl = result.baseUrl;
const response = await fetch(baseUrl, {
method: "POST",
headers: {
"Content-Type": "application/json",
Accept: "application/json, text/event-stream",
Origin: "http://localhost:3000",
},
body: JSON.stringify(TEST_MESSAGES.initialize),
});
expect(response.status).toBe(200);
});
it("should reject requests with disallowed origin headers", async () => {
const result = await createTestServerWithDnsProtection({
sessionIdGenerator: undefined,
allowedOrigins: ['http://localhost:3000'],
disableDnsRebindingProtection: false,
});
server = result.server;
transport = result.transport;
baseUrl = result.baseUrl;
const response = await fetch(baseUrl, {
method: "POST",
headers: {
"Content-Type": "application/json",
Accept: "application/json, text/event-stream",
Origin: "http://evil.com",
},
body: JSON.stringify(TEST_MESSAGES.initialize),
});
expect(response.status).toBe(403);
const body = await response.json();
expect(body.error.message).toBe("Invalid Origin header: http://evil.com");
});
});
describe("disableDnsRebindingProtection option", () => {
it("should skip all validations when disableDnsRebindingProtection is true", async () => {
const result = await createTestServerWithDnsProtection({
sessionIdGenerator: undefined,
allowedHosts: ['localhost:3001'],
allowedOrigins: ['http://localhost:3000'],
disableDnsRebindingProtection: true,
});
server = result.server;
transport = result.transport;
baseUrl = result.baseUrl;
const response = await fetch(baseUrl, {
method: "POST",
headers: {
"Content-Type": "application/json",
Accept: "application/json, text/event-stream",
Host: "evil.com",
Origin: "http://evil.com",
},
body: JSON.stringify(TEST_MESSAGES.initialize),
});
// Should pass even with invalid headers because protection is disabled
expect(response.status).toBe(200);
});
});
describe("Combined validations", () => {
it("should validate both host and origin when both are configured", async () => {
const result = await createTestServerWithDnsProtection({
sessionIdGenerator: undefined,
allowedHosts: ['localhost:3001'],
allowedOrigins: ['http://localhost:3001'],
disableDnsRebindingProtection: false,
});
server = result.server;
transport = result.transport;
baseUrl = result.baseUrl;
// Test with invalid origin (host will be automatically correct via fetch)
const response1 = await fetch(baseUrl, {
method: "POST",
headers: {
"Content-Type": "application/json",
Accept: "application/json, text/event-stream",
Origin: "http://evil.com",
},
body: JSON.stringify(TEST_MESSAGES.initialize),
});
expect(response1.status).toBe(403);
const body1 = await response1.json();
expect(body1.error.message).toBe("Invalid Origin header: http://evil.com");
// Test with valid origin
const response2 = await fetch(baseUrl, {
method: "POST",
headers: {
"Content-Type": "application/json",
Accept: "application/json, text/event-stream",
Origin: "http://localhost:3001",
},
body: JSON.stringify(TEST_MESSAGES.initialize),
});
expect(response2.status).toBe(200);
});
});
});
/**
* Helper to create test server with DNS rebinding protection options
*/
async function createTestServerWithDnsProtection(config: {
sessionIdGenerator: (() => string) | undefined;
allowedHosts?: string[];
allowedOrigins?: string[];
disableDnsRebindingProtection?: boolean;
}): Promise<{
server: Server;
transport: StreamableHTTPServerTransport;
mcpServer: McpServer;
baseUrl: URL;
}> {
const mcpServer = new McpServer(
{ name: "test-server", version: "1.0.0" },
{ capabilities: { logging: {} } }
);
const transport = new StreamableHTTPServerTransport({
sessionIdGenerator: config.sessionIdGenerator,
allowedHosts: config.allowedHosts,
allowedOrigins: config.allowedOrigins,
disableDnsRebindingProtection: config.disableDnsRebindingProtection,
});
await mcpServer.connect(transport);
const httpServer = createServer(async (req, res) => {
if (req.method === "POST") {
let body = "";
req.on("data", (chunk) => (body += chunk));
req.on("end", async () => {
const parsedBody = JSON.parse(body);
await transport.handleRequest(req as IncomingMessage & { auth?: AuthInfo }, res, parsedBody);
});
} else {
await transport.handleRequest(req as IncomingMessage & { auth?: AuthInfo }, res);
}
});
await new Promise<void>((resolve) => {
httpServer.listen(3001, () => resolve());
});
const port = (httpServer.address() as AddressInfo).port;
const serverUrl = new URL(`http://localhost:${port}/`);
return {
server: httpServer,
transport,
mcpServer,
baseUrl: serverUrl,
};
}

View file

@ -61,6 +61,24 @@ export interface StreamableHTTPServerTransportOptions {
* If provided, resumability will be enabled, allowing clients to reconnect and resume messages
*/
eventStore?: EventStore;
/**
* 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 true for backwards compatibility.
*/
disableDnsRebindingProtection?: boolean;
}
/**
@ -109,6 +127,9 @@ export class StreamableHTTPServerTransport implements Transport {
private _standaloneSseStreamId: string = '_GET_stream';
private _eventStore?: EventStore;
private _onsessioninitialized?: (sessionId: string) => void;
private _allowedHosts?: string[];
private _allowedOrigins?: string[];
private _disableDnsRebindingProtection: boolean;
sessionId?: string | undefined;
onclose?: () => void;
@ -120,6 +141,9 @@ export class StreamableHTTPServerTransport implements Transport {
this._enableJsonResponse = options.enableJsonResponse ?? false;
this._eventStore = options.eventStore;
this._onsessioninitialized = options.onsessioninitialized;
this._allowedHosts = options.allowedHosts;
this._allowedOrigins = options.allowedOrigins;
this._disableDnsRebindingProtection = options.disableDnsRebindingProtection ?? true;
}
/**
@ -133,10 +157,54 @@ export class StreamableHTTPServerTransport implements Transport {
this._started = 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._disableDnsRebindingProtection) {
return undefined;
}
// Validate Host header if allowedHosts is configured
if (this._allowedHosts && this._allowedHosts.length > 0) {
const hostHeader = req.headers.host;
if (!hostHeader || !this._allowedHosts.includes(hostHeader)) {
return `Invalid Host header: ${hostHeader}`;
}
}
// Validate Origin header if allowedOrigins is configured
if (this._allowedOrigins && this._allowedOrigins.length > 0) {
const originHeader = req.headers.origin;
if (!originHeader || !this._allowedOrigins.includes(originHeader)) {
return `Invalid Origin header: ${originHeader}`;
}
}
return undefined;
}
/**
* Handles an incoming HTTP request, whether GET or POST
*/
async handleRequest(req: IncomingMessage & { auth?: AuthInfo }, res: ServerResponse, parsedBody?: unknown): Promise<void> {
// Validate request headers for DNS rebinding protection
const validationError = this.validateRequestHeaders(req);
if (validationError) {
res.writeHead(403).end(JSON.stringify({
jsonrpc: "2.0",
error: {
code: -32000,
message: validationError
},
id: null
}));
this.onerror?.(new Error(validationError));
return;
}
if (req.method === "POST") {
await this.handlePostRequest(req, res, parsedBody);
} else if (req.method === "GET") {