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