Skip to content

Commit 6cb78ea

Browse files
authored
Merge commit from fork
Bound Request Body and Frame Reads to Prevent Memory-Exhaustion DoS
2 parents 0ede5fc + 772e0cb commit 6cb78ea

5 files changed

Lines changed: 143 additions & 3 deletions

File tree

README.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1823,6 +1823,18 @@ it also records the `Origin` header at `initialize` and rejects a later request
18231823
when both are present - a non-browser client that omits `Origin` (e.g. `curl` or a script) is not stopped by this check.
18241824
Enforcing ownership against a determined attacker requires supplying the validator with an authenticated principal.
18251825

1826+
#### Request Size Limits
1827+
1828+
`StreamableHTTPTransport` bounds how many bytes a single POST body may allocate, so a peer cannot exhaust memory
1829+
with one oversized message. A body larger than `max_request_bytes` (default 4 MiB) is rejected with HTTP 413,
1830+
and JSON nesting depth is capped. The 4 MiB default comfortably fits a typical JSON-RPC message (a 4 MiB JSON
1831+
string decodes to roughly 3 MiB of base64 payload) and matches the TypeScript SDK's 4 MB default; raise it only
1832+
if you exchange unusually large payloads:
1833+
1834+
```ruby
1835+
transport = MCP::Server::Transports::StreamableHTTPTransport.new(server, max_request_bytes: 8 * 1024 * 1024)
1836+
```
1837+
18261838
### Pagination
18271839

18281840
The MCP Ruby SDK supports [pagination](https://modelcontextprotocol.io/specification/2025-11-25/server/utilities/pagination)

lib/mcp/client/stdio.rb

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -302,6 +302,15 @@ def read_line(method, params)
302302
line = @stdout.gets("\n", @max_line_bytes)
303303
return line unless line && !line.end_with?("\n") && line.bytesize >= @max_line_bytes
304304

305+
# The over-limit frame leaves leftover bytes in the pipe, so the stream is desynced and
306+
# cannot be resumed. Close before raising so a later `send_request` fails cleanly instead
307+
# of parsing a truncated frame.
308+
begin
309+
close
310+
rescue StandardError
311+
nil
312+
end
313+
305314
raise RequestHandlerError.new(
306315
"Server response frame exceeds #{@max_line_bytes} bytes without a newline",
307316
{ method: method, params: params },

lib/mcp/server/transports/streamable_http_transport.rb

Lines changed: 52 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,18 @@ class InvalidJsonError < StandardError; end
3939
UNSET_IDLE_TIMEOUT = Object.new.freeze
4040
private_constant :UNSET_IDLE_TIMEOUT
4141

42+
# Default upper bound on the JSON-RPC request body. `handle_post` reads the whole
43+
# body into memory and parses it, so without a cap a single unauthenticated POST
44+
# can allocate gigabytes and OOM the worker. 4 MiB comfortably
45+
# fits a typical JSON-RPC request (a 4 MiB JSON string decodes to ~3 MiB of base64
46+
# payload); raise `max_request_bytes:` for unusually large payloads. Matches the
47+
# TypeScript SDK's 4 MB default.
48+
DEFAULT_MAX_REQUEST_BYTES = 4 * 1024 * 1024
49+
50+
# Conservative bound on JSON nesting depth, so a deeply nested body cannot exhaust
51+
# the stack or amplify parse cost (complements the byte cap).
52+
MAX_JSON_NESTING = 64
53+
4254
# Creates a Streamable HTTP transport that can be mounted as a Rack app.
4355
#
4456
# @param server [MCP::Server] the server whose requests this transport dispatches.
@@ -66,6 +78,8 @@ class InvalidJsonError < StandardError; end
6678
# the deploying application's responsibility (the transport never receives the authenticated identity
6779
# on its own); this is the seam to enforce ownership and mitigate session poisoning. Without a validator,
6880
# ownership is not enforced.
81+
# @param max_request_bytes [Integer] upper bound in bytes on a POST request body; larger
82+
# requests are rejected with HTTP 413. Defaults to 4 MiB.
6983
def initialize(
7084
server,
7185
stateless: false,
@@ -75,7 +89,8 @@ def initialize(
7589
allowed_origins: nil,
7690
allowed_hosts: nil,
7791
dns_rebinding_protection: true,
78-
session_request_validator: nil
92+
session_request_validator: nil,
93+
max_request_bytes: DEFAULT_MAX_REQUEST_BYTES
7994
)
8095
super(server)
8196
# Maps `session_id` to `{ get_sse_stream: stream_object, server_session: ServerSession, last_active_at: float_from_monotonic_clock, origin: origin_header }`.
@@ -115,6 +130,12 @@ def initialize(
115130
# The cap guards the stateful session store; stateless mode keeps none.
116131
@max_sessions = stateless ? nil : max_sessions
117132

133+
unless max_request_bytes.is_a?(Integer) && max_request_bytes > 0
134+
raise ArgumentError, "max_request_bytes must be a positive Integer"
135+
end
136+
137+
@max_request_bytes = max_request_bytes
138+
118139
start_reaper_thread if @session_idle_timeout
119140
end
120141

@@ -419,7 +440,9 @@ def handle_post(request)
419440
content_type_error = validate_content_type(request)
420441
return content_type_error if content_type_error
421442

422-
body_string = request.body.read
443+
body_string = read_bounded_body(request)
444+
return payload_too_large_response if body_string.nil?
445+
423446
session_id = extract_session_id(request)
424447

425448
begin
@@ -645,8 +668,34 @@ def not_acceptable_response(required_types)
645668
)
646669
end
647670

671+
# Reads the request body with a hard byte cap so an unbounded POST cannot exhaust
672+
# memory. A declared `Content-Length` over the cap is rejected
673+
# without reading; the actual read is also bounded to one byte past the cap, so
674+
# a missing or spoofed `Content-Length` (e.g. chunked transfer) is still caught.
675+
# Returns `nil` when the body exceeds the cap.
676+
def read_bounded_body(request)
677+
content_length = request.content_length
678+
return if content_length && content_length.to_i > @max_request_bytes
679+
680+
body = request.body.read(@max_request_bytes + 1)
681+
return "" if body.nil?
682+
return if body.bytesize > @max_request_bytes
683+
684+
body
685+
end
686+
687+
def payload_too_large_response
688+
json_rpc_error_response(
689+
status: 413,
690+
code: JsonRpcHandler::ErrorCode::INVALID_REQUEST,
691+
message: "Payload too large: request body exceeds #{@max_request_bytes} bytes",
692+
)
693+
end
694+
648695
def parse_request_body(body_string)
649-
JSON.parse(body_string, symbolize_names: true)
696+
# `max_nesting` bounds parse depth; a too-deep body raises `JSON::NestingError`,
697+
# a subclass of `JSON::ParserError`, so it is caught below as a parse error.
698+
JSON.parse(body_string, symbolize_names: true, max_nesting: MAX_JSON_NESTING)
650699
rescue JSON::ParserError, TypeError
651700
raise InvalidJsonError
652701
end

test/mcp/client/stdio_test.rb

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1111,6 +1111,9 @@ def test_send_request_raises_when_response_frame_exceeds_max_line_bytes
11111111

11121112
assert_match(/exceeds 1024 bytes without a newline/, error.message)
11131113
assert_equal(:internal_error, error.error_type)
1114+
# The stream is desynced (leftover bytes in the pipe), so the transport is closed
1115+
# rather than left resumable on a corrupt stream.
1116+
refute_predicate(transport, :connected?)
11141117
ensure
11151118
server_thread.join
11161119
stdin_read.close

test/mcp/server/transports/streamable_http_transport_test.rb

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4441,6 +4441,73 @@ def string
44414441
refute headers.frozen?, "SSE response headers should not be frozen"
44424442
end
44434443

4444+
test "rejects a request body over max_request_bytes with 413 without reading it all" do
4445+
transport = StreamableHTTPTransport.new(@server, max_request_bytes: 1024)
4446+
oversized = { jsonrpc: "2.0", method: "initialize", id: "1", params: { x: "A" * 4096 } }.to_json
4447+
4448+
# Content-Length present: rejected before reading.
4449+
request = create_rack_request(
4450+
"POST",
4451+
"/",
4452+
{ "CONTENT_TYPE" => "application/json", "CONTENT_LENGTH" => oversized.bytesize.to_s },
4453+
oversized,
4454+
)
4455+
response = transport.handle_request(request)
4456+
4457+
assert_equal 413, response[0]
4458+
assert_equal JsonRpcHandler::ErrorCode::INVALID_REQUEST, JSON.parse(response[2][0]).dig("error", "code")
4459+
end
4460+
4461+
test "rejects an oversized body even when Content-Length is absent" do
4462+
transport = StreamableHTTPTransport.new(@server, max_request_bytes: 1024)
4463+
oversized = { jsonrpc: "2.0", method: "initialize", id: "1", params: { x: "A" * 4096 } }.to_json
4464+
4465+
# No CONTENT_LENGTH: the bounded read must still catch it.
4466+
request = create_rack_request(
4467+
"POST",
4468+
"/",
4469+
{ "CONTENT_TYPE" => "application/json" },
4470+
oversized,
4471+
)
4472+
response = transport.handle_request(request)
4473+
4474+
assert_equal 413, response[0]
4475+
end
4476+
4477+
test "accepts a request body at the max_request_bytes boundary" do
4478+
transport = StreamableHTTPTransport.new(@server, max_request_bytes: 4096)
4479+
body = { jsonrpc: "2.0", method: "initialize", id: "1" }.to_json
4480+
assert_operator body.bytesize, :<=, 4096
4481+
4482+
request = create_rack_request(
4483+
"POST",
4484+
"/",
4485+
{ "CONTENT_TYPE" => "application/json" },
4486+
body,
4487+
)
4488+
response = transport.handle_request(request)
4489+
4490+
assert_equal 200, response[0]
4491+
end
4492+
4493+
test "rejects deeply nested JSON beyond the nesting cap as a parse error" do
4494+
depth = StreamableHTTPTransport::MAX_JSON_NESTING + 5
4495+
nested = ("[" * depth) + ("]" * depth)
4496+
body = %({"jsonrpc":"2.0","method":"initialize","id":"1","params":{"deep":#{nested}}})
4497+
4498+
request = create_rack_request("POST", "/", { "CONTENT_TYPE" => "application/json" }, body)
4499+
response = @transport.handle_request(request)
4500+
4501+
assert_equal 400, response[0]
4502+
assert_equal JsonRpcHandler::ErrorCode::PARSE_ERROR, JSON.parse(response[2][0]).dig("error", "code")
4503+
end
4504+
4505+
test "raises ArgumentError for an invalid max_request_bytes" do
4506+
[nil, 0, -1, 1.5, "1024"].each do |invalid|
4507+
assert_raises(ArgumentError) { StreamableHTTPTransport.new(@server, max_request_bytes: invalid) }
4508+
end
4509+
end
4510+
44444511
test "handle_request returns 403 for POST when Origin is not in allowed_origins" do
44454512
transport = StreamableHTTPTransport.new(@server, allowed_origins: ["https://app.example.com"])
44464513

0 commit comments

Comments
 (0)