diff --git a/README.md b/README.md index d94d84a..e3b1142 100644 --- a/README.md +++ b/README.md @@ -102,6 +102,8 @@ transport = MCP::Server::Transports::StdioTransport.new(server) transport.open ``` +`StdioTransport.new` accepts an optional `max_line_bytes:` keyword that caps the byte length of a single newline-delimited request frame. A frame that reaches this limit without a newline is rejected and the connection is closed, preventing unbounded memory growth from a peer that never emits a newline. It defaults to `4 * 1024 * 1024` (4 MiB). + You can run this script and then type in requests to the server at the command line. ```console @@ -128,6 +130,27 @@ The following examples show two common integration styles in Rails. > > Stateless mode (`stateless: true`) does not use sessions and works with any server configuration. +> [!IMPORTANT] +> Per MCP 2025-11-25, `StreamableHTTPTransport` validates the `Host` and `Origin` headers by default to +> prevent DNS rebinding attacks against locally bound servers, rejecting unauthorized values with HTTP 403. +> `Host` is allowed for the loopback defaults (`127.0.0.1`, `::1`, `localhost`), and an `Origin` header, +> when present, must be same-origin or explicitly allow-listed. Non-browser clients that send no `Origin` +> header are unaffected. +> +> Deployments behind a reverse proxy or bound to a non-loopback interface must widen the allow lists: +> +> ```ruby +> transport = MCP::Server::Transports::StreamableHTTPTransport.new( +> server, +> allowed_hosts: ["mcp.example.com"], +> allowed_origins: ["https://app.example.com"], +> ) +> ``` +> +> An `allowed_hosts:` entry matches either the bare host name (any port) or the full `host:port` value, +> so both `"mcp.example.com"` and `"mcp.example.com:8443"` work. Pass `dns_rebinding_protection: false` +> to disable the check entirely (e.g., when an upstream proxy or middleware already validates `Host`/`Origin`). + ##### Rails (mount) `StreamableHTTPTransport` is a Rack app that can be mounted directly in Rails routes: @@ -1967,6 +1990,7 @@ Use the `MCP::Client::Stdio` transport to interact with MCP servers running as s | `args:` | No | An array of arguments passed to the command. Defaults to `[]`. | | `env:` | No | A hash of environment variables to set for the server process. Defaults to `nil`. | | `read_timeout:` | No | Timeout in seconds for waiting for a server response. Defaults to `nil` (no timeout). | +| `max_line_bytes:` | No | Maximum byte length of a single newline-delimited response frame. A frame that reaches this limit without a newline is rejected as a transport error, preventing unbounded memory growth from a server that never emits a newline. Defaults to `4 * 1024 * 1024` (4 MiB). | Example usage: diff --git a/docs/building-clients.md b/docs/building-clients.md index f8f37c7..6b4c7d0 100644 --- a/docs/building-clients.md +++ b/docs/building-clients.md @@ -65,6 +65,7 @@ stdio_transport.close | `args:` | No | An array of arguments passed to the command. Defaults to `[]`. | | `env:` | No | A hash of environment variables for the server process. Defaults to `nil`. | | `read_timeout:` | No | Timeout in seconds for waiting for a server response. Defaults to `nil`. | +| `max_line_bytes:` | No | Maximum byte length of a single newline-delimited response frame. A frame that reaches this limit without a newline is rejected as a transport error, preventing unbounded memory growth from a server that never emits a newline. Defaults to `4 * 1024 * 1024` (4 MiB). | ## HTTP Transport diff --git a/docs/building-servers.md b/docs/building-servers.md index c133c70..30e2880 100644 --- a/docs/building-servers.md +++ b/docs/building-servers.md @@ -59,6 +59,8 @@ transport = MCP::Server::Transports::StdioTransport.new(server) transport.open ``` +`StdioTransport.new` accepts an optional `max_line_bytes:` keyword that caps the byte length of a single newline-delimited request frame. A frame that reaches this limit without a newline is rejected and the connection is closed, preventing unbounded memory growth from a peer that never emits a newline. It defaults to `4 * 1024 * 1024` (4 MiB). + ## Streamable HTTP Transport `MCP::Server::Transports::StreamableHTTPTransport` is a standard Rack app, so it can be mounted in any Rack-compatible framework. diff --git a/lib/mcp/client/stdio.rb b/lib/mcp/client/stdio.rb index a1fe8b4..f7670dd 100644 --- a/lib/mcp/client/stdio.rb +++ b/lib/mcp/client/stdio.rb @@ -19,13 +19,28 @@ module MCP CLOSE_TIMEOUT = 2 STDERR_READ_SIZE = 4096 + # Default upper bound on a single newline-delimited frame read from the + # server's stdout. CRuby's `IO#gets` without a limit accumulates bytes until a + # newline arrives, so a spawned server that never emits one can grow a single + # String until the host process is OOM-killed. 4 MiB is large enough for any + # realistic JSON-RPC frame, including base64-embedded images. + MAX_LINE_BYTES = 4 * 1024 * 1024 + attr_reader :command, :args, :env, :server_info - def initialize(command:, args: [], env: nil, read_timeout: nil) + def initialize(command:, args: [], env: nil, read_timeout: nil, max_line_bytes: MAX_LINE_BYTES) + # Reject `nil` or non-positive values: `IO#gets("\n", nil)` and a negative + # limit read without an upper bound, which would silently disable the + # protection this option exists to provide. + unless max_line_bytes.is_a?(Integer) && max_line_bytes > 0 + raise ArgumentError, "max_line_bytes must be a positive Integer" + end + @command = command @args = args @env = env @read_timeout = read_timeout + @max_line_bytes = max_line_bytes @stdin = nil @stdout = nil @stderr = nil @@ -239,7 +254,7 @@ module MCP loop do ensure_running! wait_for_readable!(method, params) if @read_timeout - line = @stdout.gets + line = read_line(method, params) raise_connection_error!(method, params) if line.nil? parsed = JSON.parse(line.strip) @@ -278,6 +293,22 @@ module MCP ) end + # Reads one newline-delimited frame from the server's stdout, bounded by `@max_line_bytes`. + # Returns the line (including its trailing newline) or `nil` at EOF. Raises when the limit + # is reached before a newline arrives, which signals a server streaming an unbounded frame. + # A short final frame without a trailing newline (EOF) is still returned, since its length + # stays under the limit. + def read_line(method, params) + line = @stdout.gets("\n", @max_line_bytes) + return line unless line && !line.end_with?("\n") && line.bytesize >= @max_line_bytes + + raise RequestHandlerError.new( + "Server response frame exceeds #{@max_line_bytes} bytes without a newline", + { method: method, params: params }, + error_type: :internal_error, + ) + end + def raise_connection_error!(method, params) raise RequestHandlerError.new( "Server process closed stdout unexpectedly", diff --git a/lib/mcp/server/transports/stdio_transport.rb b/lib/mcp/server/transports/stdio_transport.rb index c96f526..ec2bd6d 100644 --- a/lib/mcp/server/transports/stdio_transport.rb +++ b/lib/mcp/server/transports/stdio_transport.rb @@ -9,10 +9,25 @@ module MCP class StdioTransport < Transport STATUS_INTERRUPTED = Signal.list["INT"] + 128 - def initialize(server) + # Default upper bound on a single newline-delimited frame. CRuby's `IO#gets` + # without a limit accumulates bytes until a newline arrives, so a peer that + # never emits one can grow a single String until the process is OOM-killed. + # 4 MiB is large enough for any realistic JSON-RPC frame, including + # base64-embedded images. + MAX_LINE_BYTES = 4 * 1024 * 1024 + + def initialize(server, max_line_bytes: MAX_LINE_BYTES) super(server) @open = false @session = nil + # Reject `nil` or non-positive values: `IO#gets("\n", nil)` and a negative + # limit read without an upper bound, which would silently disable the + # protection this option exists to provide. + unless max_line_bytes.is_a?(Integer) && max_line_bytes > 0 + raise ArgumentError, "max_line_bytes must be a positive Integer" + end + + @max_line_bytes = max_line_bytes $stdin.set_encoding("UTF-8") $stdout.set_encoding("UTF-8") end @@ -20,7 +35,20 @@ module MCP def open @open = true @session = ServerSession.new(server: @server, transport: self) - while @open && (line = $stdin.gets) + while @open + begin + line = read_line($stdin) + rescue RequestHandlerError => e + # Stop accumulating and end the connection gracefully rather than + # letting an unbounded read exhaust memory or escape as an uncaught + # backtrace. Scoped to the read so genuine request errors raised while + # handling a frame are not swallowed here. + @open = false + MCP.configuration.exception_reporter.call(e, { error: "stdio frame exceeds limit" }) + break + end + break if line.nil? + response = @session.handle_json(line.strip) send_response(response) if response end @@ -73,7 +101,7 @@ module MCP raise end - while @open && (line = $stdin.gets) + while @open && (line = read_line($stdin)) begin parsed = JSON.parse(line.strip, symbolize_names: true) rescue JSON::ParserError => e @@ -95,6 +123,26 @@ module MCP raise "Transport closed while waiting for response to #{method} request." end + + private + + # Reads one newline-delimited frame, bounded by `@max_line_bytes`. Returns + # the line (including its trailing newline) or `nil` at EOF. Raises when the + # limit is reached before a newline arrives, which signals a peer streaming + # an unbounded frame. A short final frame without a trailing newline (EOF) is + # still returned, since its length stays under the limit. + def read_line(io) + line = io.gets("\n", @max_line_bytes) + if line && !line.end_with?("\n") && line.bytesize >= @max_line_bytes + raise RequestHandlerError.new( + "stdio frame exceeds #{@max_line_bytes} bytes without a newline", + nil, + error_type: :internal_error, + ) + end + + line + end end end end diff --git a/lib/mcp/server/transports/streamable_http_transport.rb b/lib/mcp/server/transports/streamable_http_transport.rb index e79017e..fc63988 100644 --- a/lib/mcp/server/transports/streamable_http_transport.rb +++ b/lib/mcp/server/transports/streamable_http_transport.rb @@ -39,8 +39,35 @@ module MCP UNSET_IDLE_TIMEOUT = Object.new.freeze private_constant :UNSET_IDLE_TIMEOUT - def initialize(server, stateless: false, enable_json_response: false, - session_idle_timeout: UNSET_IDLE_TIMEOUT, max_sessions: DEFAULT_MAX_SESSIONS) + # Creates a Streamable HTTP transport that can be mounted as a Rack app. + # + # @param server [MCP::Server] the server whose requests this transport dispatches. + # @param stateless [Boolean] when `true`, no session is issued and each POST is self-contained. + # @param enable_json_response [Boolean] when `true`, a request is answered with a single JSON + # object instead of an SSE stream. + # @param session_idle_timeout [Numeric, nil] seconds before an idle session is reaped; defaults + # to `DEFAULT_SESSION_IDLE_TIMEOUT` (1800) in stateful mode, and an explicit `nil` disables + # expiry. Not supported in stateless mode. + # @param max_sessions [Integer, nil] cap on the concurrent session count in stateful mode; a new + # `initialize` past the cap is rejected with HTTP 503, and `nil` disables the cap. + # @param allowed_origins [Array, nil] extra `Origin` values accepted in addition to + # same-origin requests, for DNS rebinding protection. + # @param allowed_hosts [Array, nil] extra `Host` values accepted beyond the loopback + # defaults (`127.0.0.1`, `::1`, `localhost`); each entry matches a bare host name (any port) + # or a full `host:port`. + # @param dns_rebinding_protection [Boolean] when `true` (default), validates the `Host` and + # `Origin` headers to prevent DNS rebinding; pass `false` when an upstream proxy already + # validates them. + def initialize( + server, + stateless: false, + enable_json_response: false, + session_idle_timeout: UNSET_IDLE_TIMEOUT, + max_sessions: DEFAULT_MAX_SESSIONS, + allowed_origins: nil, + allowed_hosts: nil, + dns_rebinding_protection: true + ) super(server) # Maps `session_id` to `{ get_sse_stream: stream_object, server_session: ServerSession, last_active_at: float_from_monotonic_clock }`. @sessions = {} @@ -48,6 +75,11 @@ module MCP @stateless = stateless @enable_json_response = enable_json_response + @dns_rebinding_protection = dns_rebinding_protection + + # Host names are case-insensitive, so the allow lists are compared down-cased. + @allowed_hosts = (DEFAULT_LOOPBACK_HOSTS + Array(allowed_hosts)).map(&:downcase).freeze + @allowed_origins = Array(allowed_origins).map(&:downcase).freeze @pending_responses = {} # Resolve the idle timeout: an explicit value (including `nil` to opt out) wins; otherwise apply the secure default, @@ -82,12 +114,19 @@ module MCP STREAM_WRITE_ERRORS = [IOError, Errno::EPIPE, Errno::ECONNRESET].freeze SESSION_REAP_INTERVAL = 60 + # Loopback hosts always accepted by DNS rebinding protection. A locally bound MCP server (the canonical pattern) is + # protected out of the box; non-loopback deployments widen the list via `allowed_hosts:`. + DEFAULT_LOOPBACK_HOSTS = ["127.0.0.1", "::1", "localhost"].freeze + # Rack app interface. This transport can be mounted as a Rack app. def call(env) handle_request(Rack::Request.new(env)) end def handle_request(request) + rebinding_error = validate_dns_rebinding(request) + return rebinding_error if rebinding_error + case request.env["REQUEST_METHOD"] when "POST" handle_post(request) @@ -863,6 +902,74 @@ module MCP active end + # Per MCP 2025-11-25, servers MUST validate the `Origin` header and SHOULD bind only to localhost + # to prevent DNS rebinding attacks against locally bound MCP servers. Protection is on by default; + # pass `dns_rebinding_protection: false` to disable it (e.g. when an upstream proxy or middleware already + # performs the check). The `Host` header is validated against the loopback defaults plus `allowed_hosts:`, + # and the `Origin` header, when present, must be same-origin or in `allowed_origins:`. + def validate_dns_rebinding(request) + return unless @dns_rebinding_protection + + validate_host(request) || validate_origin(request) + end + + # Rejects a rebound `Host` (e.g. `evil.example.com` re-pointed at 127.0.0.1). + # A request without a `Host` header (e.g. HTTP/1.0) is allowed; the rebinding vector this guards against always carries one. + def validate_host(request) + host = request.env["HTTP_HOST"] + return if host.nil? + + # An `allowed_hosts:` entry matches either the bare host name (any port) + # or the full `host:port` value, so both `"app.example.com"` and + # `"app.example.com:8443"` can be configured. + normalized = host.downcase + return if @allowed_hosts.include?(request_hostname(normalized)) || @allowed_hosts.include?(normalized) + + forbidden_response("Forbidden: Invalid Host header") + end + + # A request without an `Origin` header (typical for non-browser MCP clients) is allowed. A browser cross-origin request is + # rejected unless the origin is same-origin or explicitly allow-listed via `allowed_origins:`. + def validate_origin(request) + origin = request.env["HTTP_ORIGIN"] + return if origin.nil? + return if same_origin?(origin, request) + return if @allowed_origins.include?(origin.downcase) + + forbidden_response("Forbidden: Invalid Origin header") + end + + # Extracts the host name from a `Host` header value, stripping any port and IPv6 brackets + # (`[::1]:8080` becomes `::1`, `127.0.0.1:8080` becomes `127.0.0.1`). + def request_hostname(host) + return host[/\A\[([^\]]+)\]/, 1] if host.start_with?("[") + + host.split(":").first + end + + # Compares the `Origin` authority (host:port) against the request's own `Host`. + # Scheme is not compared (the `Host` header carries none, and `request.scheme` is unreliable behind proxies), + # but the `Origin`'s scheme is used to drop a redundant default port (`:80` for http, `:443` for https) from + # both sides so `http://example.com` matches `Host: example.com:80`. Comparison is case-insensitive. + def same_origin?(origin, request) + host = request.env["HTTP_HOST"] + return false if host.nil? + + normalized = origin.downcase + default_port = normalized.start_with?("https://") ? ":443" : ":80" + authority = normalized.sub(%r{\Ahttps?://}, "") + + authority.delete_suffix(default_port) == host.downcase.delete_suffix(default_port) + end + + def forbidden_response(message) + json_rpc_error_response( + status: 403, + code: JsonRpcHandler::ErrorCode::INVALID_REQUEST, + message: message, + ) + end + def method_not_allowed_response json_rpc_error_response( status: 405, diff --git a/test/mcp/client/stdio_test.rb b/test/mcp/client/stdio_test.rb index 9807a1b..e57aef6 100644 --- a/test/mcp/client/stdio_test.rb +++ b/test/mcp/client/stdio_test.rb @@ -1060,6 +1060,116 @@ module MCP end end + def test_new_raises_argument_error_when_max_line_bytes_is_not_a_positive_integer + [nil, 0, -1, 1.5, "1024"].each do |invalid| + error = assert_raises(ArgumentError) do + Stdio.new(command: "ruby", args: ["server.rb"], max_line_bytes: invalid) + end + assert_equal("max_line_bytes must be a positive Integer", error.message) + end + end + + def test_send_request_raises_when_response_frame_exceeds_max_line_bytes + stdin_read, stdin_write = IO.pipe + stdout_read, stdout_write = IO.pipe + stderr_read, _ = IO.pipe + + Open3.stubs(:popen3).returns([stdin_write, stdout_read, stderr_read, mock_wait_thread]) + + transport = Stdio.new(command: "ruby", args: ["server.rb"], max_line_bytes: 1024) + + request = { jsonrpc: "2.0", id: "test-id", method: "tools/list" } + + server_thread = Thread.new do + init_line = stdin_read.gets + init_request = JSON.parse(init_line) + stdout_write.puts(JSON.generate({ + jsonrpc: "2.0", + id: init_request["id"], + result: { + protocolVersion: "2025-11-25", + capabilities: {}, + serverInfo: { name: "test-server", version: "1.0.0" }, + }, + })) + stdout_write.flush + + # Read initialized notification. + stdin_read.gets + + # Read tools/list request, then stream an unbounded frame with no newline. + stdin_read.gets + stdout_write.write("A" * 2000) + stdout_write.flush + end + + transport.connect + + error = assert_raises(RequestHandlerError) do + transport.send_request(request: request) + end + + assert_match(/exceeds 1024 bytes without a newline/, error.message) + assert_equal(:internal_error, error.error_type) + ensure + server_thread.join + stdin_read.close + stdin_write.close + stdout_read.close + stdout_write.close + end + + def test_send_request_accepts_response_within_custom_max_line_bytes + stdin_read, stdin_write = IO.pipe + stdout_read, stdout_write = IO.pipe + stderr_read, _ = IO.pipe + + Open3.stubs(:popen3).returns([stdin_write, stdout_read, stderr_read, mock_wait_thread]) + + transport = Stdio.new(command: "ruby", args: ["server.rb"], max_line_bytes: 1024) + + request = { jsonrpc: "2.0", id: "test-id", method: "tools/list" } + + server_thread = Thread.new do + init_line = stdin_read.gets + init_request = JSON.parse(init_line) + stdout_write.puts(JSON.generate({ + jsonrpc: "2.0", + id: init_request["id"], + result: { + protocolVersion: "2025-11-25", + capabilities: {}, + serverInfo: { name: "test-server", version: "1.0.0" }, + }, + })) + stdout_write.flush + + stdin_read.gets + + tools_line = stdin_read.gets + tools_request = JSON.parse(tools_line) + stdout_write.puts(JSON.generate({ + jsonrpc: "2.0", + id: tools_request["id"], + result: { tools: [] }, + })) + stdout_write.flush + end + + transport.connect + + response = transport.send_request(request: request) + + assert_equal("test-id", response["id"]) + assert_equal({ "tools" => [] }, response["result"]) + ensure + server_thread.join + stdin_read.close + stdin_write.close + stdout_read.close + stdout_write.close + end + def test_send_notification_writes_json_line_without_waiting_for_response stdin_read, stdin_write = IO.pipe stdout_read, stdout_write = IO.pipe diff --git a/test/mcp/server/transports/stdio_transport_test.rb b/test/mcp/server/transports/stdio_transport_test.rb index 4671960..c9cda78 100644 --- a/test/mcp/server/transports/stdio_transport_test.rb +++ b/test/mcp/server/transports/stdio_transport_test.rb @@ -441,6 +441,91 @@ module MCP end end end + + test "raises ArgumentError when max_line_bytes is not a positive Integer" do + [nil, 0, -1, 1.5, "1024"].each do |invalid| + error = assert_raises(ArgumentError) do + StdioTransport.new(@server, max_line_bytes: invalid) + end + assert_equal("max_line_bytes must be a positive Integer", error.message) + end + end + + test "open stops and reports when a stdin frame exceeds max_line_bytes without a newline" do + transport = StdioTransport.new(@server, max_line_bytes: 64) + input = StringIO.new("A" * 200) # No newline: an unbounded frame. + output = StringIO.new + + reported = [] + original_stdin = $stdin + original_stdout = $stdout + original_reporter = MCP.configuration.exception_reporter + + begin + $stdin = input + $stdout = output + MCP.configuration.exception_reporter = ->(e, ctx) { reported << [e, ctx] } + + transport.open + + refute(transport.instance_variable_get(:@open)) + assert_equal(1, reported.size) + error, _ctx = reported.first + assert_instance_of(RequestHandlerError, error) + assert_match(/exceeds 64 bytes without a newline/, error.message) + ensure + $stdin = original_stdin + $stdout = original_stdout + MCP.configuration.exception_reporter = original_reporter + end + end + + test "open processes a valid final frame that ends at EOF without a trailing newline" do + request = { jsonrpc: "2.0", method: "ping", id: "eof" } + input = StringIO.new(JSON.generate(request)) # No trailing newline. + output = StringIO.new + + original_stdin = $stdin + original_stdout = $stdout + + begin + $stdin = input + $stdout = output + + @transport.open + + response = JSON.parse(output.string, symbolize_names: true) + assert_equal("eof", response[:id]) + assert_equal({}, response[:result]) + ensure + $stdin = original_stdin + $stdout = original_stdout + end + end + + test "send_request raises when a response frame exceeds max_line_bytes without a newline" do + transport = StdioTransport.new(@server, max_line_bytes: 64) + input = StringIO.new("A" * 200) # No newline: an unbounded frame. + output = StringIO.new + + original_stdin = $stdin + original_stdout = $stdout + + begin + $stdin = input + $stdout = output + transport.instance_variable_set(:@open, true) + + error = assert_raises(RequestHandlerError) do + transport.send_request("sampling/createMessage", { messages: [] }) + end + + assert_match(/exceeds 64 bytes without a newline/, error.message) + ensure + $stdin = original_stdin + $stdout = original_stdout + end + end end end end diff --git a/test/mcp/server/transports/streamable_http_transport_test.rb b/test/mcp/server/transports/streamable_http_transport_test.rb index ece1f77..13bb40f 100644 --- a/test/mcp/server/transports/streamable_http_transport_test.rb +++ b/test/mcp/server/transports/streamable_http_transport_test.rb @@ -4441,6 +4441,306 @@ module MCP refute headers.frozen?, "SSE response headers should not be frozen" end + test "handle_request returns 403 for POST when Origin is not in allowed_origins" do + transport = StreamableHTTPTransport.new(@server, allowed_origins: ["https://app.example.com"]) + + request = create_rack_request( + "POST", + "/", + { + "CONTENT_TYPE" => "application/json", + "HTTP_ORIGIN" => "https://evil.example.com", + }, + { jsonrpc: "2.0", method: "ping", id: "1" }.to_json, + ) + + status, headers, body = transport.handle_request(request) + assert_equal(403, status) + assert_equal("application/json", headers["Content-Type"]) + parsed = JSON.parse(body.first) + assert_equal(JsonRpcHandler::ErrorCode::INVALID_REQUEST, parsed["error"]["code"]) + assert_equal("Forbidden: Invalid Origin header", parsed["error"]["message"]) + ensure + transport&.close + end + + test "handle_request returns 403 for GET when Origin is not in allowed_origins" do + transport = StreamableHTTPTransport.new(@server, allowed_origins: ["https://app.example.com"]) + + request = create_rack_request( + "GET", + "/", + { "HTTP_ORIGIN" => "https://evil.example.com" }, + ) + + status, = transport.handle_request(request) + assert_equal(403, status) + ensure + transport&.close + end + + test "handle_request returns 403 for DELETE when Origin is not in allowed_origins" do + transport = StreamableHTTPTransport.new(@server, allowed_origins: ["https://app.example.com"]) + + request = create_rack_request( + "DELETE", + "/", + { + "HTTP_ORIGIN" => "https://evil.example.com", + "HTTP_MCP_SESSION_ID" => "any", + }, + ) + + status, = transport.handle_request(request) + assert_equal(403, status) + ensure + transport&.close + end + + test "handle_request allows POST when Origin is in allowed_origins" do + transport = StreamableHTTPTransport.new(@server, allowed_origins: ["https://app.example.com"]) + + request = create_rack_request( + "POST", + "/", + { + "CONTENT_TYPE" => "application/json", + "HTTP_ORIGIN" => "https://app.example.com", + }, + { jsonrpc: "2.0", method: "initialize", id: "init" }.to_json, + ) + + status, = transport.handle_request(request) + assert_equal(200, status) + ensure + transport&.close + end + + test "handle_request allows POST when Origin header is absent (e.g. non-browser client)" do + transport = StreamableHTTPTransport.new(@server, allowed_origins: ["https://app.example.com"]) + + request = create_rack_request( + "POST", + "/", + { "CONTENT_TYPE" => "application/json" }, + { jsonrpc: "2.0", method: "initialize", id: "init" }.to_json, + ) + + status, = transport.handle_request(request) + assert_equal(200, status) + ensure + transport&.close + end + + test "handle_request rejects a cross-origin request by default (secure-by-default)" do + # No allowed_origins configured: a browser cross-origin Origin is still rejected. + request = create_rack_request( + "POST", + "/", + { + "CONTENT_TYPE" => "application/json", + "HTTP_ORIGIN" => "https://anywhere.example.com", + }, + { jsonrpc: "2.0", method: "initialize", id: "init" }.to_json, + ) + + status, = @transport.handle_request(request) + assert_equal 403, status + end + + test "handle_request allows a same-origin browser request" do + request = create_rack_request( + "POST", + "/", + { + "CONTENT_TYPE" => "application/json", + "HTTP_HOST" => "localhost:3000", + "HTTP_ORIGIN" => "http://localhost:3000", + }, + { jsonrpc: "2.0", method: "initialize", id: "init" }.to_json, + ) + + status, = @transport.handle_request(request) + assert_equal 200, status + end + + test "handle_request rejects a rebound Host by default" do + # DNS rebinding: a foreign Host re-pointed at the loopback server is rejected. + request = create_rack_request( + "POST", + "/", + { + "CONTENT_TYPE" => "application/json", + "HTTP_HOST" => "evil.example.com", + }, + { jsonrpc: "2.0", method: "initialize", id: "init" }.to_json, + ) + + status, headers, body = @transport.handle_request(request) + assert_equal(403, status) + assert_equal("application/json", headers["Content-Type"]) + parsed = JSON.parse(body.first) + assert_equal(JsonRpcHandler::ErrorCode::INVALID_REQUEST, parsed["error"]["code"]) + assert_equal("Forbidden: Invalid Host header", parsed["error"]["message"]) + end + + test "handle_request allows loopback Host values by default" do + ["127.0.0.1:8080", "localhost:3000", "[::1]:8080"].each do |host| + transport = StreamableHTTPTransport.new(@server) + request = create_rack_request( + "POST", + "/", + { + "CONTENT_TYPE" => "application/json", + "HTTP_HOST" => host, + }, + { jsonrpc: "2.0", method: "initialize", id: "init" }.to_json, + ) + + status, = transport.handle_request(request) + assert_equal(200, status, "expected #{host} to be allowed") + ensure + transport.close + end + end + + test "handle_request matches Host case-insensitively" do + # Host names are case-insensitive, so an upper-cased loopback Host is allowed. + request = create_rack_request( + "POST", + "/", + { + "CONTENT_TYPE" => "application/json", + "HTTP_HOST" => "LOCALHOST:3000", + }, + { jsonrpc: "2.0", method: "initialize", id: "init" }.to_json, + ) + + status, = @transport.handle_request(request) + assert_equal(200, status) + end + + test "handle_request allows a non-loopback Host listed in allowed_hosts" do + transport = StreamableHTTPTransport.new(@server, allowed_hosts: ["app.example.com"]) + + request = create_rack_request( + "POST", + "/", + { + "CONTENT_TYPE" => "application/json", + "HTTP_HOST" => "app.example.com", + }, + { jsonrpc: "2.0", method: "initialize", id: "init" }.to_json, + ) + + status, = transport.handle_request(request) + assert_equal(200, status) + ensure + transport.close + end + + test "handle_request matches a host:port entry in allowed_hosts exactly" do + transport = StreamableHTTPTransport.new(@server, allowed_hosts: ["app.example.com:8443"]) + + allowed = create_rack_request( + "POST", + "/", + { "CONTENT_TYPE" => "application/json", "HTTP_HOST" => "app.example.com:8443" }, + { jsonrpc: "2.0", method: "initialize", id: "init" }.to_json, + ) + rejected = create_rack_request( + "POST", + "/", + { "CONTENT_TYPE" => "application/json", "HTTP_HOST" => "app.example.com:9000" }, + { jsonrpc: "2.0", method: "initialize", id: "init" }.to_json, + ) + + assert_equal(200, transport.handle_request(allowed).first) + assert_equal(403, transport.handle_request(rejected).first) + ensure + transport.close + end + + test "handle_request treats a default port as same-origin" do + # `Origin: http://localhost` (no port) is same-origin with `Host: localhost:80`. + [ + ["http://localhost", "localhost:80"], + ["https://localhost", "localhost:443"], + ].each do |origin, host| + transport = StreamableHTTPTransport.new(@server) + request = create_rack_request( + "POST", + "/", + { "CONTENT_TYPE" => "application/json", "HTTP_HOST" => host, "HTTP_ORIGIN" => origin }, + { jsonrpc: "2.0", method: "initialize", id: "init" }.to_json, + ) + + assert_equal(200, transport.handle_request(request).first, "expected #{origin} / #{host} to match") + ensure + transport.close + end + end + + test "handle_request rejects opaque and malformed Origins" do + ["null", "file:///etc/passwd", "http://localhost:3000/path"].each do |origin| + transport = StreamableHTTPTransport.new(@server) + request = create_rack_request( + "POST", + "/", + { "CONTENT_TYPE" => "application/json", "HTTP_HOST" => "localhost:3000", "HTTP_ORIGIN" => origin }, + { jsonrpc: "2.0", method: "initialize", id: "init" }.to_json, + ) + + assert_equal(403, transport.handle_request(request).first, "expected #{origin} to be rejected") + ensure + transport.close + end + end + + test "handle_request enforces Host validation on GET requests" do + request = create_rack_request( + "GET", + "/", + { "HTTP_HOST" => "evil.example.com" }, + ) + + status, = @transport.handle_request(request) + assert_equal(403, status) + end + + test "handle_request allows a request with no Host header" do + # Non-browser clients may omit Host; the rebinding vector always carries one. + request = create_rack_request( + "POST", + "/", + { "CONTENT_TYPE" => "application/json" }, + { jsonrpc: "2.0", method: "initialize", id: "init" }.to_json, + ) + + status, = @transport.handle_request(request) + assert_equal(200, status) + end + + test "handle_request skips DNS rebinding checks when protection is disabled" do + transport = StreamableHTTPTransport.new(@server, dns_rebinding_protection: false) + + request = create_rack_request( + "POST", + "/", + { + "CONTENT_TYPE" => "application/json", + "HTTP_HOST" => "evil.example.com", + "HTTP_ORIGIN" => "https://evil.example.com", + }, + { jsonrpc: "2.0", method: "initialize", id: "init" }.to_json, + ) + + status, = transport.handle_request(request) + assert_equal(200, status) + ensure + transport.close + end + private def create_rack_request(method, path, headers, body = nil)