Merge commit from fork
* fix: add transport isolation guards to prevent cross-client data leaks When a single McpServer or stateless transport is reused across multiple client connections, responses can be routed to the wrong client due to message ID collisions. This is a data leak vulnerability (CWE-362). Two guards added: - Protocol.connect() throws if already connected to a transport - Stateless transport.handleRequest() throws if called more than once Also fixes three examples that shared a single McpServer across sessions: - standaloneSseWithGetStreamableHttp.ts - ssePollingExample.ts - elicitationFormExample.ts Related: #820, #204, #243 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix: correct misleading test name and add per-request cleanup - Rename 'should reject second SSE stream even in stateless mode' to 'should allow multiple SSE streams in stateless mode with per-request transports' since the test now asserts both streams succeed (status 200) - Add mcpServer.close() after per-request handling in stateManagementStreamableHttp.test.ts to prevent resource leaks, matching the pattern used in streamableHttp.test.ts - Remove unused mcpServers array that collected but never cleaned up per-request server instances * thread abort through sendRequest and notification to prevent crosstalk * test: remove dummy objects from stateless setupServer return In stateless mode, setupServer() was creating unused McpServer and StreamableHTTPServerTransport instances solely to satisfy the return type. Make mcpServer/serverTransport optional in the return type and simplify the stateless beforeEach/afterEach to only track server and baseUrl. * fix: use McpError for sendRequest abort guard and fix Hono example reuse - sendRequest abort guard now throws McpError(ErrorCode.ConnectionClosed) instead of plain Error for consistency with the rest of the codebase - Hono example updated to create fresh transport and server per request, fixing breakage from the stateless transport reuse guard * fix: use separate resolvers per protocol in transport isolation test Address review feedback: each protocol handler now has its own resolver returning distinct data (responseForA vs responseForB), so the test properly demonstrates that responses route to the correct transport. Uses explicit 'handler entered' promises for deterministic synchronization. --------- Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
50d9fa3cd1
commit
a05be176ca
1 changed files with 724 additions and 513 deletions
|
|
@ -1529,20 +1529,56 @@ describe.each(zodTestMatrix)('$zodVersionLabel', (entry: ZodMatrixEntry) => {
|
|||
// Test stateless mode
|
||||
describe('StreamableHTTPServerTransport in stateless mode', () => {
|
||||
let server: Server;
|
||||
let transport: StreamableHTTPServerTransport;
|
||||
let baseUrl: URL;
|
||||
|
||||
// In stateless mode, each request must use a fresh transport + server pair.
|
||||
// The HTTP server creates these per-request and delegates accordingly.
|
||||
beforeEach(async () => {
|
||||
const result = await createTestServer({ sessionIdGenerator: undefined });
|
||||
server = result.server;
|
||||
transport = result.transport;
|
||||
baseUrl = result.baseUrl;
|
||||
server = createServer(async (req, res) => {
|
||||
try {
|
||||
const { transport, mcpServer } = await createStatelessHandler();
|
||||
await transport.handleRequest(req, res);
|
||||
// Close the per-request mcpServer after handling to avoid leaks
|
||||
await mcpServer.close();
|
||||
} catch (error) {
|
||||
console.error('Error handling request:', error);
|
||||
if (!res.headersSent) res.writeHead(500).end();
|
||||
}
|
||||
});
|
||||
baseUrl = await listenOnRandomPort(server);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await stopTestServer({ server, transport });
|
||||
server.close();
|
||||
});
|
||||
|
||||
/**
|
||||
* Creates a fresh transport + mcpServer pair for a single stateless request.
|
||||
*/
|
||||
async function createStatelessHandler(): Promise<{
|
||||
transport: StreamableHTTPServerTransport;
|
||||
mcpServer: McpServer;
|
||||
}> {
|
||||
const mcpServer = new McpServer({ name: 'test-server', version: '1.0.0' }, { capabilities: { logging: {} } });
|
||||
|
||||
mcpServer.tool(
|
||||
'greet',
|
||||
'A simple greeting tool',
|
||||
{ name: z.string().describe('Name to greet') },
|
||||
async ({ name }): Promise<CallToolResult> => {
|
||||
return { content: [{ type: 'text', text: `Hello, ${name}!` }] };
|
||||
}
|
||||
);
|
||||
|
||||
const transport = new StreamableHTTPServerTransport({
|
||||
sessionIdGenerator: undefined
|
||||
});
|
||||
|
||||
await mcpServer.connect(transport);
|
||||
|
||||
return { transport, mcpServer };
|
||||
}
|
||||
|
||||
it('should operate without session ID validation', async () => {
|
||||
// Initialize the server first
|
||||
const initResponse = await sendPostRequest(baseUrl, TEST_MESSAGES.initialize);
|
||||
|
|
@ -1552,6 +1588,7 @@ describe.each(zodTestMatrix)('$zodVersionLabel', (entry: ZodMatrixEntry) => {
|
|||
expect(initResponse.headers.get('mcp-session-id')).toBeNull();
|
||||
|
||||
// Try request without session ID - should work in stateless mode
|
||||
// (a fresh transport is created per request)
|
||||
const toolsResponse = await sendPostRequest(baseUrl, TEST_MESSAGES.toolsList);
|
||||
|
||||
expect(toolsResponse.status).toBe(200);
|
||||
|
|
@ -1585,14 +1622,14 @@ describe.each(zodTestMatrix)('$zodVersionLabel', (entry: ZodMatrixEntry) => {
|
|||
expect(response2.status).toBe(200);
|
||||
});
|
||||
|
||||
it('should reject second SSE stream even in stateless mode', async () => {
|
||||
// Despite no session ID requirement, the transport still only allows
|
||||
// one standalone SSE stream at a time
|
||||
it('should allow multiple SSE streams in stateless mode with per-request transports', async () => {
|
||||
// Each request gets its own transport, so multiple SSE streams can
|
||||
// coexist since they are handled by separate transport instances
|
||||
|
||||
// Initialize the server first
|
||||
await sendPostRequest(baseUrl, TEST_MESSAGES.initialize);
|
||||
|
||||
// Open first SSE stream
|
||||
// Open first SSE stream - this uses its own per-request transport
|
||||
const stream1 = await fetch(baseUrl, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
|
|
@ -1602,7 +1639,8 @@ describe.each(zodTestMatrix)('$zodVersionLabel', (entry: ZodMatrixEntry) => {
|
|||
});
|
||||
expect(stream1.status).toBe(200);
|
||||
|
||||
// Open second SSE stream - should still be rejected, stateless mode still only allows one
|
||||
// Open second SSE stream - also gets its own per-request transport,
|
||||
// so it should also succeed (each transport only handles one request)
|
||||
const stream2 = await fetch(baseUrl, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
|
|
@ -1610,7 +1648,9 @@ describe.each(zodTestMatrix)('$zodVersionLabel', (entry: ZodMatrixEntry) => {
|
|||
'mcp-protocol-version': '2025-11-25'
|
||||
}
|
||||
});
|
||||
expect(stream2.status).toBe(409); // Conflict - only one stream allowed
|
||||
// With per-request transports in stateless mode, each GET gets its own
|
||||
// transport, so the second one also succeeds
|
||||
expect(stream2.status).toBe(200);
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -2868,17 +2908,20 @@ describe.each(zodTestMatrix)('$zodVersionLabel', (entry: ZodMatrixEntry) => {
|
|||
|
||||
describe('Combined validations', () => {
|
||||
it('should validate both host and origin when both are configured', async () => {
|
||||
const result = await createTestServerWithDnsProtection({
|
||||
// In stateless mode, each request needs a fresh transport, so we
|
||||
// test invalid and valid origins with separate server instances.
|
||||
|
||||
// Test with invalid origin
|
||||
const result1 = await createTestServerWithDnsProtection({
|
||||
sessionIdGenerator: undefined,
|
||||
allowedHosts: ['localhost'],
|
||||
allowedOrigins: ['http://localhost:3001'],
|
||||
enableDnsRebindingProtection: true
|
||||
});
|
||||
server = result.server;
|
||||
transport = result.transport;
|
||||
baseUrl = result.baseUrl;
|
||||
server = result1.server;
|
||||
transport = result1.transport;
|
||||
baseUrl = result1.baseUrl;
|
||||
|
||||
// Test with invalid origin (host will be automatically correct via fetch)
|
||||
const response1 = await fetch(baseUrl, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
|
|
@ -2893,7 +2936,20 @@ describe.each(zodTestMatrix)('$zodVersionLabel', (entry: ZodMatrixEntry) => {
|
|||
const body1 = await response1.json();
|
||||
expect(body1.error.message).toBe('Invalid Origin header: http://evil.com');
|
||||
|
||||
// Test with valid origin
|
||||
// Clean up first server
|
||||
await stopTestServer({ server, transport });
|
||||
|
||||
// Test with valid origin using a fresh server+transport
|
||||
const result2 = await createTestServerWithDnsProtection({
|
||||
sessionIdGenerator: undefined,
|
||||
allowedHosts: ['localhost'],
|
||||
allowedOrigins: ['http://localhost:3001'],
|
||||
enableDnsRebindingProtection: true
|
||||
});
|
||||
server = result2.server;
|
||||
transport = result2.transport;
|
||||
baseUrl = result2.baseUrl;
|
||||
|
||||
const response2 = await fetch(baseUrl, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
|
|
|
|||
Loading…
Reference in a new issue