Skip to content

Commit dfc3c73

Browse files
Theodor N. EngøyTheodor N. Engøy
authored andcommitted
server: enforce maxBodyBytes when parsing JSON
1 parent 65bbcea commit dfc3c73

File tree

3 files changed

+121
-2
lines changed

3 files changed

+121
-2
lines changed
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
'@modelcontextprotocol/server': patch
3+
---
4+
5+
Add a `maxBodyBytes` option to `WebStandardStreamableHTTPServerTransport` and enforce it while parsing incoming JSON request bodies.
6+

packages/server/src/server/streamableHttp.ts

Lines changed: 88 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,18 @@ import {
2121
export type StreamId = string;
2222
export type EventId = string;
2323

24+
const DEFAULT_MAX_BODY_BYTES = 1_000_000;
25+
26+
class PayloadTooLargeError extends Error {
27+
readonly maxBodyBytes: number;
28+
29+
constructor(maxBodyBytes: number) {
30+
super('Payload too large');
31+
this.name = 'PayloadTooLargeError';
32+
this.maxBodyBytes = maxBodyBytes;
33+
}
34+
}
35+
2436
/**
2537
* Interface for resumability support via event storage
2638
*/
@@ -107,6 +119,16 @@ export interface WebStandardStreamableHTTPServerTransportOptions {
107119
*/
108120
enableJsonResponse?: boolean;
109121

122+
/**
123+
* Maximum size in bytes that this transport will read when parsing an `application/json` request body.
124+
* This is a basic DoS guard for servers that call `transport.handleRequest(req)` without an upstream body-size limit.
125+
*
126+
* Set to `0` (or any non-finite value like `Infinity`) to disable the limit (not recommended).
127+
*
128+
* @default 1_000_000
129+
*/
130+
maxBodyBytes?: number;
131+
110132
/**
111133
* Event store for resumability support
112134
* If provided, resumability will be enabled, allowing clients to reconnect and resume messages
@@ -222,6 +244,7 @@ export class WebStandardStreamableHTTPServerTransport implements Transport {
222244
private _requestResponseMap: Map<RequestId, JSONRPCMessage> = new Map();
223245
private _initialized: boolean = false;
224246
private _enableJsonResponse: boolean = false;
247+
private _maxBodyBytes?: number;
225248
private _standaloneSseStreamId: string = '_GET_stream';
226249
private _eventStore?: EventStore;
227250
private _onsessioninitialized?: (sessionId: string) => void | Promise<void>;
@@ -240,6 +263,8 @@ export class WebStandardStreamableHTTPServerTransport implements Transport {
240263
constructor(options: WebStandardStreamableHTTPServerTransportOptions = {}) {
241264
this.sessionIdGenerator = options.sessionIdGenerator;
242265
this._enableJsonResponse = options.enableJsonResponse ?? false;
266+
const maxBodyBytes = options.maxBodyBytes ?? DEFAULT_MAX_BODY_BYTES;
267+
this._maxBodyBytes = Number.isFinite(maxBodyBytes) && maxBodyBytes > 0 ? maxBodyBytes : undefined;
243268
this._eventStore = options.eventStore;
244269
this._onsessioninitialized = options.onsessioninitialized;
245270
this._onsessionclosed = options.onsessionclosed;
@@ -298,6 +323,64 @@ export class WebStandardStreamableHTTPServerTransport implements Transport {
298323
);
299324
}
300325

326+
private async parseJsonRequestBody(req: Request): Promise<unknown> {
327+
if (this._maxBodyBytes === undefined) {
328+
return req.json();
329+
}
330+
331+
const maxBodyBytes = this._maxBodyBytes;
332+
333+
// Quick reject when content-length is present and exceeds the limit.
334+
const contentLengthHeader = req.headers.get('content-length');
335+
if (contentLengthHeader) {
336+
const contentLength = Number(contentLengthHeader);
337+
if (Number.isFinite(contentLength) && contentLength > maxBodyBytes) {
338+
throw new PayloadTooLargeError(maxBodyBytes);
339+
}
340+
}
341+
342+
const reader = req.body?.getReader();
343+
if (!reader) {
344+
// Fall back to the platform JSON parsing if the body stream is unavailable.
345+
return req.json();
346+
}
347+
348+
const chunks: Uint8Array[] = [];
349+
let totalBytes = 0;
350+
351+
while (true) {
352+
const { done, value } = await reader.read();
353+
if (done) {
354+
break;
355+
}
356+
if (!value) {
357+
continue;
358+
}
359+
360+
totalBytes += value.byteLength;
361+
if (totalBytes > maxBodyBytes) {
362+
try {
363+
await reader.cancel();
364+
} catch {
365+
// Best-effort.
366+
}
367+
throw new PayloadTooLargeError(maxBodyBytes);
368+
}
369+
370+
chunks.push(value);
371+
}
372+
373+
const bodyBytes = new Uint8Array(totalBytes);
374+
let offset = 0;
375+
for (const chunk of chunks) {
376+
bodyBytes.set(chunk, offset);
377+
offset += chunk.byteLength;
378+
}
379+
380+
const bodyText = new TextDecoder().decode(bodyBytes);
381+
return JSON.parse(bodyText) as unknown;
382+
}
383+
301384
/**
302385
* Validates request headers for DNS rebinding protection.
303386
* @returns Error response if validation fails, undefined if validation passes.
@@ -626,8 +709,11 @@ export class WebStandardStreamableHTTPServerTransport implements Transport {
626709
let rawMessage;
627710
if (options?.parsedBody === undefined) {
628711
try {
629-
rawMessage = await req.json();
630-
} catch {
712+
rawMessage = await this.parseJsonRequestBody(req);
713+
} catch (error) {
714+
if (error instanceof PayloadTooLargeError) {
715+
return this.createJsonErrorResponse(413, -32_000, 'Payload too large');
716+
}
631717
return this.createJsonErrorResponse(400, -32_700, 'Parse error: Invalid JSON');
632718
}
633719
} else {

packages/server/test/server/streamableHttp.test.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -333,6 +333,33 @@ describe('Zod v4', () => {
333333
expectErrorResponse(errorData, -32_700, /Parse error.*Invalid JSON/);
334334
});
335335

336+
it('should reject JSON bodies larger than maxBodyBytes', async () => {
337+
const limitedTransport = new WebStandardStreamableHTTPServerTransport({
338+
sessionIdGenerator: () => randomUUID(),
339+
maxBodyBytes: 256
340+
});
341+
342+
const bigInit: JSONRPCMessage = {
343+
...TEST_MESSAGES.initialize,
344+
params: {
345+
...(TEST_MESSAGES.initialize as any).params,
346+
clientInfo: {
347+
...(TEST_MESSAGES.initialize as any).params.clientInfo,
348+
name: 'a'.repeat(1024)
349+
}
350+
}
351+
};
352+
353+
const request = createRequest('POST', bigInit);
354+
const response = await limitedTransport.handleRequest(request);
355+
356+
expect(response.status).toBe(413);
357+
const errorData = await response.json();
358+
expectErrorResponse(errorData, -32_000, /Payload too large/);
359+
360+
await limitedTransport.close();
361+
});
362+
336363
it('should accept notifications without session and return 202', async () => {
337364
sessionId = await initializeServer();
338365

0 commit comments

Comments
 (0)