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:
commit
dfdd4efc24
3 changed files with 715 additions and 7 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Reference in a new issue