Merge upstream modelcontextprotocol/ruby-sdk main

Resolves conflicts in lib/mcp/server/transports/streamable_http_transport.rb
between the DNS rebinding protection merged upstream (allowed_origins,
allowed_hosts, dns_rebinding_protection keywords) and this branch's session
retention hardening (session_idle_timeout default and max_sessions keyword)
by combining both sets of keyword arguments and constructor logic.
This commit is contained in:
Koichi ITO 2026-07-07 21:28:11 +09:00
commit dfdd4efc24
9 changed files with 715 additions and 7 deletions

View file

@ -102,6 +102,8 @@ transport = MCP::Server::Transports::StdioTransport.new(server)
transport.open 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. You can run this script and then type in requests to the server at the command line.
```console ```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. > 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) ##### Rails (mount)
`StreamableHTTPTransport` is a Rack app that can be mounted directly in Rails routes: `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 `[]`. | | `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`. | | `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). | | `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: Example usage:

View file

@ -65,6 +65,7 @@ stdio_transport.close
| `args:` | No | An array of arguments passed to the command. Defaults to `[]`. | | `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`. | | `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`. | | `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 ## HTTP Transport

View file

@ -59,6 +59,8 @@ transport = MCP::Server::Transports::StdioTransport.new(server)
transport.open 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 ## Streamable HTTP Transport
`MCP::Server::Transports::StreamableHTTPTransport` is a standard Rack app, so it can be mounted in any Rack-compatible framework. `MCP::Server::Transports::StreamableHTTPTransport` is a standard Rack app, so it can be mounted in any Rack-compatible framework.

View file

@ -19,13 +19,28 @@ module MCP
CLOSE_TIMEOUT = 2 CLOSE_TIMEOUT = 2
STDERR_READ_SIZE = 4096 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 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 @command = command
@args = args @args = args
@env = env @env = env
@read_timeout = read_timeout @read_timeout = read_timeout
@max_line_bytes = max_line_bytes
@stdin = nil @stdin = nil
@stdout = nil @stdout = nil
@stderr = nil @stderr = nil
@ -239,7 +254,7 @@ module MCP
loop do loop do
ensure_running! ensure_running!
wait_for_readable!(method, params) if @read_timeout wait_for_readable!(method, params) if @read_timeout
line = @stdout.gets line = read_line(method, params)
raise_connection_error!(method, params) if line.nil? raise_connection_error!(method, params) if line.nil?
parsed = JSON.parse(line.strip) parsed = JSON.parse(line.strip)
@ -278,6 +293,22 @@ module MCP
) )
end 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) def raise_connection_error!(method, params)
raise RequestHandlerError.new( raise RequestHandlerError.new(
"Server process closed stdout unexpectedly", "Server process closed stdout unexpectedly",

View file

@ -9,10 +9,25 @@ module MCP
class StdioTransport < Transport class StdioTransport < Transport
STATUS_INTERRUPTED = Signal.list["INT"] + 128 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) super(server)
@open = false @open = false
@session = nil @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") $stdin.set_encoding("UTF-8")
$stdout.set_encoding("UTF-8") $stdout.set_encoding("UTF-8")
end end
@ -20,7 +35,20 @@ module MCP
def open def open
@open = true @open = true
@session = ServerSession.new(server: @server, transport: self) @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) response = @session.handle_json(line.strip)
send_response(response) if response send_response(response) if response
end end
@ -73,7 +101,7 @@ module MCP
raise raise
end end
while @open && (line = $stdin.gets) while @open && (line = read_line($stdin))
begin begin
parsed = JSON.parse(line.strip, symbolize_names: true) parsed = JSON.parse(line.strip, symbolize_names: true)
rescue JSON::ParserError => e rescue JSON::ParserError => e
@ -95,6 +123,26 @@ module MCP
raise "Transport closed while waiting for response to #{method} request." raise "Transport closed while waiting for response to #{method} request."
end 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 end
end end

View file

@ -39,8 +39,35 @@ module MCP
UNSET_IDLE_TIMEOUT = Object.new.freeze UNSET_IDLE_TIMEOUT = Object.new.freeze
private_constant :UNSET_IDLE_TIMEOUT private_constant :UNSET_IDLE_TIMEOUT
def initialize(server, stateless: false, enable_json_response: false, # Creates a Streamable HTTP transport that can be mounted as a Rack app.
session_idle_timeout: UNSET_IDLE_TIMEOUT, max_sessions: DEFAULT_MAX_SESSIONS) #
# @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<String>, nil] extra `Origin` values accepted in addition to
# same-origin requests, for DNS rebinding protection.
# @param allowed_hosts [Array<String>, 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) super(server)
# Maps `session_id` to `{ get_sse_stream: stream_object, server_session: ServerSession, last_active_at: float_from_monotonic_clock }`. # Maps `session_id` to `{ get_sse_stream: stream_object, server_session: ServerSession, last_active_at: float_from_monotonic_clock }`.
@sessions = {} @sessions = {}
@ -48,6 +75,11 @@ module MCP
@stateless = stateless @stateless = stateless
@enable_json_response = enable_json_response @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 = {} @pending_responses = {}
# Resolve the idle timeout: an explicit value (including `nil` to opt out) wins; otherwise apply the secure default, # 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 STREAM_WRITE_ERRORS = [IOError, Errno::EPIPE, Errno::ECONNRESET].freeze
SESSION_REAP_INTERVAL = 60 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. # Rack app interface. This transport can be mounted as a Rack app.
def call(env) def call(env)
handle_request(Rack::Request.new(env)) handle_request(Rack::Request.new(env))
end end
def handle_request(request) def handle_request(request)
rebinding_error = validate_dns_rebinding(request)
return rebinding_error if rebinding_error
case request.env["REQUEST_METHOD"] case request.env["REQUEST_METHOD"]
when "POST" when "POST"
handle_post(request) handle_post(request)
@ -863,6 +902,74 @@ module MCP
active active
end 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 def method_not_allowed_response
json_rpc_error_response( json_rpc_error_response(
status: 405, status: 405,

View file

@ -1060,6 +1060,116 @@ module MCP
end end
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 def test_send_notification_writes_json_line_without_waiting_for_response
stdin_read, stdin_write = IO.pipe stdin_read, stdin_write = IO.pipe
stdout_read, stdout_write = IO.pipe stdout_read, stdout_write = IO.pipe

View file

@ -441,6 +441,91 @@ module MCP
end end
end 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 end
end end

View file

@ -4441,6 +4441,306 @@ module MCP
refute headers.frozen?, "SSE response headers should not be frozen" refute headers.frozen?, "SSE response headers should not be frozen"
end 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 private
def create_rack_request(method, path, headers, body = nil) def create_rack_request(method, path, headers, body = nil)