Skip to content

Commit a93fbf8

Browse files
authored
Merge pull request #639 from koic/parse_request_headers_as_bytes
Parse request headers as bytes so invalid UTF-8 cannot raise
2 parents 28741fd + 61edd2c commit a93fbf8

2 files changed

Lines changed: 60 additions & 5 deletions

File tree

‎lib/mcp/server/transports/streamable_http_transport.rb‎

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1899,7 +1899,7 @@ def session_principal_matches?(session, request)
18991899
end
19001900

19011901
def validate_accept_header(request, required_types)
1902-
accept_header = request.env["HTTP_ACCEPT"]
1902+
accept_header = header_bytes(request, "HTTP_ACCEPT")
19031903
return not_acceptable_response(required_types) unless accept_header
19041904

19051905
accepted_types = parse_accept_header(accept_header)
@@ -1918,7 +1918,7 @@ def parse_accept_header(header)
19181918
end
19191919

19201920
def validate_content_type(request)
1921-
content_type = request.env["CONTENT_TYPE"]
1921+
content_type = header_bytes(request, "CONTENT_TYPE")
19221922
media_type = content_type&.split(";")&.first&.strip&.downcase
19231923
return if media_type == "application/json"
19241924

@@ -2428,10 +2428,18 @@ def validate_dns_rebinding(request)
24282428
validate_host(request) || validate_origin(request)
24292429
end
24302430

2431+
# A header value as bytes. Rack servers hand header values over as ASCII-8BIT, where every byte is valid, but a framework
2432+
# or a test request can tag them UTF-8, and then `downcase`, `split`, and regexps raise on an invalid byte sequence
2433+
# before any rescue applies. Reading the value as bytes puts both on the same footing: a malformed value fails its comparison
2434+
# and gets the rejection that comparison produces, instead of failing the request.
2435+
def header_bytes(request, name)
2436+
request.env[name]&.b
2437+
end
2438+
24312439
# Rejects a rebound `Host` (e.g. `evil.example.com` re-pointed at 127.0.0.1).
24322440
# A request without a `Host` header (e.g. HTTP/1.0) is allowed; the rebinding vector this guards against always carries one.
24332441
def validate_host(request)
2434-
host = request.env["HTTP_HOST"]
2442+
host = header_bytes(request, "HTTP_HOST")
24352443
return if host.nil?
24362444

24372445
# An `allowed_hosts:` entry matches either the bare host name (any port)
@@ -2446,7 +2454,7 @@ def validate_host(request)
24462454
# A request without an `Origin` header (typical for non-browser MCP clients) is allowed. A browser cross-origin request is
24472455
# rejected unless the origin is same-origin or explicitly allow-listed via `allowed_origins:`.
24482456
def validate_origin(request)
2449-
origin = request.env["HTTP_ORIGIN"]
2457+
origin = header_bytes(request, "HTTP_ORIGIN")
24502458
return if origin.nil?
24512459
return if same_origin?(origin, request)
24522460
return if @allowed_origins.include?(origin.downcase)
@@ -2467,7 +2475,7 @@ def request_hostname(host)
24672475
# but the `Origin`'s scheme is used to drop a redundant default port (`:80` for http, `:443` for https) from
24682476
# both sides so `http://example.com` matches `Host: example.com:80`. Comparison is case-insensitive.
24692477
def same_origin?(origin, request)
2470-
host = request.env["HTTP_HOST"]
2478+
host = header_bytes(request, "HTTP_HOST")
24712479
return false if host.nil?
24722480

24732481
normalized = origin.downcase

‎test/mcp/server/transports/streamable_http_transport_test.rb‎

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5750,6 +5750,53 @@ def string
57505750
end
57515751
end
57525752

5753+
test "handle_request rejects headers carrying invalid UTF-8 bytes instead of raising" do
5754+
# A Rack server hands headers over as ASCII-8BIT, but a test request or a framework can tag them UTF-8,
5755+
# and String#downcase, #split, and regexps raise on invalid bytes in a UTF-8 string.
5756+
invalid = ->(value) { "#{value}\xFF".dup.force_encoding(Encoding::UTF_8) }
5757+
body = { jsonrpc: "2.0", method: "ping", id: "1" }.to_json
5758+
expectations = [
5759+
[{ "HTTP_HOST" => invalid.call("localhost") }, 403, "Forbidden: Invalid Host header"],
5760+
[{ "HTTP_ORIGIN" => invalid.call("https://app.example.com") }, 403, "Forbidden: Invalid Origin header"],
5761+
[{ "HTTP_ACCEPT" => invalid.call("application/json") }, 406, nil],
5762+
[{ "CONTENT_TYPE" => invalid.call("application/json") }, 415, nil],
5763+
]
5764+
5765+
expectations.each do |headers, expected_status, expected_message|
5766+
request = create_rack_request("POST", "/", { "CONTENT_TYPE" => "application/json" }.merge(headers), body)
5767+
5768+
status, _headers, response_body = @transport.handle_request(request)
5769+
5770+
assert_equal expected_status, status, headers.keys.first
5771+
assert_equal expected_message, JSON.parse(response_body.first)["error"]["message"] if expected_message
5772+
end
5773+
end
5774+
5775+
test "GET request with an Accept header carrying invalid UTF-8 bytes returns 406 instead of raising" do
5776+
# The GET path validates `Accept` outside any rescue, so the raise reached the web server before.
5777+
init_request = create_rack_request(
5778+
"POST",
5779+
"/",
5780+
{ "CONTENT_TYPE" => "application/json" },
5781+
{ jsonrpc: "2.0", method: "initialize", id: "123", params: initialize_params }.to_json,
5782+
)
5783+
session_id = @transport.handle_request(init_request)[1]["mcp-session-id"]
5784+
5785+
request = create_rack_request(
5786+
"GET",
5787+
"/",
5788+
{
5789+
"HTTP_MCP_SESSION_ID" => session_id,
5790+
"HTTP_ACCEPT" => "text/event-stream\xFF".dup.force_encoding(Encoding::UTF_8),
5791+
},
5792+
)
5793+
5794+
status, _headers, body = @transport.handle_request(request)
5795+
5796+
assert_equal 406, status
5797+
assert_equal JsonRpcHandler::ErrorCode::INVALID_REQUEST, JSON.parse(body.first)["error"]["code"]
5798+
end
5799+
57535800
test "handle_request returns 403 for POST when Origin is not in allowed_origins" do
57545801
transport = StreamableHTTPTransport.new(@server, allowed_origins: ["https://app.example.com"])
57555802

0 commit comments

Comments
 (0)