Add protections for streamable HTTP too
This commit is contained in:
parent
adbacc6388
commit
ebf2535b90
3 changed files with 351 additions and 2 deletions
22
README.md
22
README.md
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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") {
|
||||
|
|
|
|||
Loading…
Reference in a new issue