Bound Request Body and Frame Reads to Prevent Memory-Exhaustion DoS

## Motivation and Context

Both transports read a peer-controlled message fully into memory with no upper bound,
so a single unauthenticated message can allocate gigabytes and OOM the worker:

- `StreamableHTTPTransport#handle_post` did `request.body.read` with no size limit
  before `JSON.parse`. A single ~512 MB POST grew a worker from 44 MB to 1.66 GB RSS
  in the reporter's measurement. The path runs before session validation,
  so it is reachable unauthenticated in both stateful and `stateless: true` modes.
- `StdioTransport` and `MCP::Client::Stdio` read frames with `IO#gets` and no `limit:`,
  so a peer that never emits a newline grows a single String until the process is OOM-killed.
  This matters when a host pipes an untrusted server's stdout into an unconstrained process.

The spec does not mandate a request size limit, and the reference SDKs vary
(the TypeScript SDK caps at a 4 MB default; Python/Go delegate to the web framework).
A finite default here bounds the allocation at the transport layer regardless of the deployment,
matching the TypeScript SDK's default.

- `StreamableHTTPTransport` gains `max_request_bytes:` (default 4 MiB). `handle_post`
  rejects a declared `Content-Length` over the cap before reading, and also bounds
  the actual read to one byte past the cap so a missing or spoofed `Content-Length`
  (chunked transfer) is still caught, returning HTTP 413. `parse_request_body` now passes
  `max_nesting:` (64) so a deeply nested body cannot exhaust the stack (complements the byte cap).
  The constructor validates the new option as a positive Integer, since `nil` or a non-positive
  value makes the underlying read unbounded and would silently disable the protection.
- The stdio `max_line_bytes:` bound (default 4 MiB) has since been merged upstream through
  a separate advisory, so this change carries the remaining piece of the reported stdio fix:
  an over-limit frame leaves leftover bytes in the pipe, desyncing the stream beyond recovery,
  so `MCP::Client::Stdio` now closes the transport before raising and a later `send_request`
  fails cleanly instead of parsing a truncated frame.

## How Has This Been Tested?

New tests in `test/mcp/server/transports/streamable_http_transport_test.rb` cover the 413 rejection
with and without `Content-Length`, the boundary body being accepted, the nesting-depth rejection,
and the constructor validation. The client stdio over-limit test now also asserts the transport
is closed afterward (`refute_predicate(transport, :connected?)`).

`bundle exec rake` (tests, RuboCop, and conformance baseline) passes. Verified end-to-end that
a 256 MB POST with no declared `Content-Length` is rejected with 413 without the transport reading
the whole body (a throttled `rack.input` that raises if read far past the cap is never tripped).

## Breaking Changes

None. The 4 MiB defaults comfortably fit a typical JSON-RPC message (a 4 MiB JSON string decodes to
roughly 3 MiB of base64 payload), and the new keyword arguments are additive and optional.
This commit is contained in:
Koichi ITO 2026-07-07 21:38:25 +09:00
commit 772e0cb1f9
2 changed files with 143 additions and 3 deletions

View file

@ -302,6 +302,15 @@ module MCP
line = @stdout.gets("\n", @max_line_bytes)
return line unless line && !line.end_with?("\n") && line.bytesize >= @max_line_bytes
# The over-limit frame leaves leftover bytes in the pipe, so the stream is desynced and
# cannot be resumed. Close before raising so a later `send_request` fails cleanly instead
# of parsing a truncated frame.
begin
close
rescue StandardError
nil
end
raise RequestHandlerError.new(
"Server response frame exceeds #{@max_line_bytes} bytes without a newline",
{ method: method, params: params },

View file

@ -39,6 +39,18 @@ module MCP
UNSET_IDLE_TIMEOUT = Object.new.freeze
private_constant :UNSET_IDLE_TIMEOUT
# Default upper bound on the JSON-RPC request body. `handle_post` reads the whole
# body into memory and parses it, so without a cap a single unauthenticated POST
# can allocate gigabytes and OOM the worker. 4 MiB comfortably
# fits a typical JSON-RPC request (a 4 MiB JSON string decodes to ~3 MiB of base64
# payload); raise `max_request_bytes:` for unusually large payloads. Matches the
# TypeScript SDK's 4 MB default.
DEFAULT_MAX_REQUEST_BYTES = 4 * 1024 * 1024
# Conservative bound on JSON nesting depth, so a deeply nested body cannot exhaust
# the stack or amplify parse cost (complements the byte cap).
MAX_JSON_NESTING = 64
# Creates a Streamable HTTP transport that can be mounted as a Rack app.
#
# @param server [MCP::Server] the server whose requests this transport dispatches.
@ -66,6 +78,8 @@ module MCP
# the deploying application's responsibility (the transport never receives the authenticated identity
# on its own); this is the seam to enforce ownership and mitigate session poisoning. Without a validator,
# ownership is not enforced.
# @param max_request_bytes [Integer] upper bound in bytes on a POST request body; larger
# requests are rejected with HTTP 413. Defaults to 4 MiB.
def initialize(
server,
stateless: false,
@ -75,7 +89,8 @@ module MCP
allowed_origins: nil,
allowed_hosts: nil,
dns_rebinding_protection: true,
session_request_validator: nil
session_request_validator: nil,
max_request_bytes: DEFAULT_MAX_REQUEST_BYTES
)
super(server)
# 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 @@ module MCP
# The cap guards the stateful session store; stateless mode keeps none.
@max_sessions = stateless ? nil : max_sessions
unless max_request_bytes.is_a?(Integer) && max_request_bytes > 0
raise ArgumentError, "max_request_bytes must be a positive Integer"
end
@max_request_bytes = max_request_bytes
start_reaper_thread if @session_idle_timeout
end
@ -419,7 +440,9 @@ module MCP
content_type_error = validate_content_type(request)
return content_type_error if content_type_error
body_string = request.body.read
body_string = read_bounded_body(request)
return payload_too_large_response if body_string.nil?
session_id = extract_session_id(request)
begin
@ -645,8 +668,34 @@ module MCP
)
end
# Reads the request body with a hard byte cap so an unbounded POST cannot exhaust
# memory. A declared `Content-Length` over the cap is rejected
# without reading; the actual read is also bounded to one byte past the cap, so
# a missing or spoofed `Content-Length` (e.g. chunked transfer) is still caught.
# Returns `nil` when the body exceeds the cap.
def read_bounded_body(request)
content_length = request.content_length
return if content_length && content_length.to_i > @max_request_bytes
body = request.body.read(@max_request_bytes + 1)
return "" if body.nil?
return if body.bytesize > @max_request_bytes
body
end
def payload_too_large_response
json_rpc_error_response(
status: 413,
code: JsonRpcHandler::ErrorCode::INVALID_REQUEST,
message: "Payload too large: request body exceeds #{@max_request_bytes} bytes",
)
end
def parse_request_body(body_string)
JSON.parse(body_string, symbolize_names: true)
# `max_nesting` bounds parse depth; a too-deep body raises `JSON::NestingError`,
# a subclass of `JSON::ParserError`, so it is caught below as a parse error.
JSON.parse(body_string, symbolize_names: true, max_nesting: MAX_JSON_NESTING)
rescue JSON::ParserError, TypeError
raise InvalidJsonError
end