Validate Host and Origin Headers to Prevent DNS Rebinding per MCP 2025-11-25
## Motivation and Context MCP 2025-11-25 (modelcontextprotocol/modelcontextprotocol#1439) requires servers to validate the `Origin` header on incoming Streamable HTTP connections and recommends binding only to localhost, to prevent DNS rebinding attacks against locally bound MCP servers. `StreamableHTTPTransport` previously validated neither `Host` nor `Origin`, so a malicious web page could rebind its own hostname to 127.0.0.1 and reach a local server cross-origin, invoking tools and exfiltrating their output. This is disclosed as GHSA-rjr6-rcgv-9m7m. The TypeScript SDK (`enableDnsRebindingProtection` defaults to `false`, and is deprecated in favor of external middleware) and the Python SDK (`transport_security.py` disables protection by default) both ship this off for backwards compatibility, a constraint they cannot escape after 1.0. Because this gem is still pre-1.0, it adopts the secure default now so protection is baked into the stable baseline: - `Host` is validated against the loopback defaults (`127.0.0.1`, `::1`, `localhost`) plus any `allowed_hosts:`, compared case-insensitively; each entry matches the bare host name (any port) or a full `host:port`. This check is the primary anti-rebinding control, because a rebound request carries a foreign `Host`. A request without a `Host` header (e.g. HTTP/1.0) is allowed because the rebinding vector always sends one. - `Origin`, when present, must be same-origin or in `allowed_origins:`. Same-origin compares authority (host:port) case-insensitively and uses the `Origin`'s own scheme to drop a redundant default port, so `http://example.com` matches `Host: example.com:80`. Non-browser MCP clients that send no `Origin` header are unaffected. - `dns_rebinding_protection: false` disables the check when an upstream proxy or middleware already validates these headers. A rejected request returns HTTP 403 with a JSON-RPC error response carrying no `id`, as the spec permits. ## How Has This Been Tested? New tests in `test/mcp/server/transports/streamable_http_transport_test.rb`: - a cross-origin `Origin` is rejected by default, and a same-origin browser request is allowed - a rebound foreign `Host` is rejected with the `Invalid Host header` body, while loopback values (`127.0.0.1`, `localhost`, `[::1]`) are allowed - an upper-cased loopback `Host` is accepted (case-insensitive matching) - a non-loopback `Host` listed in `allowed_hosts:` is allowed - a request with no `Host` header, and one with no `Origin` header, are allowed - a default-port `Origin` (`http://localhost`) matches `Host: localhost:80` - opaque and malformed `Origin`s (`null`, `file://`, a path suffix) are rejected - a `host:port` `allowed_hosts:` entry matches that port only - `Host` validation also applies to GET requests - `dns_rebinding_protection: false` lets a forged `Host`/`Origin` through ## Breaking Changes `StreamableHTTPTransport` now rejects non-loopback `Host` and cross-origin `Origin` requests by default. A deployment behind a reverse proxy or bound to a non-loopback interface must set `allowed_hosts:` / `allowed_origins:`, or pass `dns_rebinding_protection: false` when validation is handled upstream. Loopback servers and non-browser clients are unaffected. Co-authored-by: tonghuaroot <tonghuaroot@gmail.com>
This commit is contained in:
parent
5518e8db03
commit
ba543083a7
3 changed files with 426 additions and 1 deletions
21
README.md
21
README.md
|
|
@ -128,6 +128,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:
|
||||||
|
|
|
||||||
|
|
@ -24,7 +24,31 @@ module MCP
|
||||||
"Connection" => "keep-alive",
|
"Connection" => "keep-alive",
|
||||||
}.freeze
|
}.freeze
|
||||||
|
|
||||||
def initialize(server, stateless: false, enable_json_response: false, session_idle_timeout: nil)
|
# 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; `nil`
|
||||||
|
# disables expiry. Not supported in stateless mode.
|
||||||
|
# @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: nil,
|
||||||
|
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 = {}
|
||||||
|
|
@ -33,6 +57,11 @@ module MCP
|
||||||
@stateless = stateless
|
@stateless = stateless
|
||||||
@enable_json_response = enable_json_response
|
@enable_json_response = enable_json_response
|
||||||
@session_idle_timeout = session_idle_timeout
|
@session_idle_timeout = session_idle_timeout
|
||||||
|
@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 = {}
|
||||||
|
|
||||||
if @session_idle_timeout
|
if @session_idle_timeout
|
||||||
|
|
@ -52,12 +81,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)
|
||||||
|
|
@ -808,6 +844,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,
|
||||||
|
|
|
||||||
|
|
@ -4328,6 +4328,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)
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue