@@ -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
0 commit comments