Skip to content

Commit cfccb7c

Browse files
authored
Merge commit from fork
Validate `Host` and `Origin` Headers to Prevent DNS Rebinding per MCP 2025-11-25
2 parents c893075 + ba54308 commit cfccb7c

3 files changed

Lines changed: 426 additions & 1 deletion

File tree

‎README.md‎

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,27 @@ The following examples show two common integration styles in Rails.
130130
>
131131
> Stateless mode (`stateless: true`) does not use sessions and works with any server configuration.
132132
133+
> [!IMPORTANT]
134+
> Per MCP 2025-11-25, `StreamableHTTPTransport` validates the `Host` and `Origin` headers by default to
135+
> prevent DNS rebinding attacks against locally bound servers, rejecting unauthorized values with HTTP 403.
136+
> `Host` is allowed for the loopback defaults (`127.0.0.1`, `::1`, `localhost`), and an `Origin` header,
137+
> when present, must be same-origin or explicitly allow-listed. Non-browser clients that send no `Origin`
138+
> header are unaffected.
139+
>
140+
> Deployments behind a reverse proxy or bound to a non-loopback interface must widen the allow lists:
141+
>
142+
> ```ruby
143+
> transport = MCP::Server::Transports::StreamableHTTPTransport.new(
144+
> server,
145+
> allowed_hosts: ["mcp.example.com"],
146+
> allowed_origins: ["https://app.example.com"],
147+
> )
148+
> ```
149+
>
150+
> An `allowed_hosts:` entry matches either the bare host name (any port) or the full `host:port` value,
151+
> so both `"mcp.example.com"` and `"mcp.example.com:8443"` work. Pass `dns_rebinding_protection: false`
152+
> to disable the check entirely (e.g., when an upstream proxy or middleware already validates `Host`/`Origin`).
153+
133154
##### Rails (mount)
134155
135156
`StreamableHTTPTransport` is a Rack app that can be mounted directly in Rails routes:

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

Lines changed: 105 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,31 @@ class InvalidJsonError < StandardError; end
2424
"Connection" => "keep-alive",
2525
}.freeze
2626

27-
def initialize(server, stateless: false, enable_json_response: false, session_idle_timeout: nil)
27+
# Creates a Streamable HTTP transport that can be mounted as a Rack app.
28+
#
29+
# @param server [MCP::Server] the server whose requests this transport dispatches.
30+
# @param stateless [Boolean] when `true`, no session is issued and each POST is self-contained.
31+
# @param enable_json_response [Boolean] when `true`, a request is answered with a single JSON
32+
# object instead of an SSE stream.
33+
# @param session_idle_timeout [Numeric, nil] seconds before an idle session is reaped; `nil`
34+
# disables expiry. Not supported in stateless mode.
35+
# @param allowed_origins [Array<String>, nil] extra `Origin` values accepted in addition to
36+
# same-origin requests, for DNS rebinding protection.
37+
# @param allowed_hosts [Array<String>, nil] extra `Host` values accepted beyond the loopback
38+
# defaults (`127.0.0.1`, `::1`, `localhost`); each entry matches a bare host name (any port)
39+
# or a full `host:port`.
40+
# @param dns_rebinding_protection [Boolean] when `true` (default), validates the `Host` and
41+
# `Origin` headers to prevent DNS rebinding; pass `false` when an upstream proxy already
42+
# validates them.
43+
def initialize(
44+
server,
45+
stateless: false,
46+
enable_json_response: false,
47+
session_idle_timeout: nil,
48+
allowed_origins: nil,
49+
allowed_hosts: nil,
50+
dns_rebinding_protection: true
51+
)
2852
super(server)
2953
# Maps `session_id` to `{ get_sse_stream: stream_object, server_session: ServerSession, last_active_at: float_from_monotonic_clock }`.
3054
@sessions = {}
@@ -33,6 +57,11 @@ def initialize(server, stateless: false, enable_json_response: false, session_id
3357
@stateless = stateless
3458
@enable_json_response = enable_json_response
3559
@session_idle_timeout = session_idle_timeout
60+
@dns_rebinding_protection = dns_rebinding_protection
61+
62+
# Host names are case-insensitive, so the allow lists are compared down-cased.
63+
@allowed_hosts = (DEFAULT_LOOPBACK_HOSTS + Array(allowed_hosts)).map(&:downcase).freeze
64+
@allowed_origins = Array(allowed_origins).map(&:downcase).freeze
3665
@pending_responses = {}
3766

3867
if @session_idle_timeout
@@ -52,12 +81,19 @@ def initialize(server, stateless: false, enable_json_response: false, session_id
5281
STREAM_WRITE_ERRORS = [IOError, Errno::EPIPE, Errno::ECONNRESET].freeze
5382
SESSION_REAP_INTERVAL = 60
5483

84+
# Loopback hosts always accepted by DNS rebinding protection. A locally bound MCP server (the canonical pattern) is
85+
# protected out of the box; non-loopback deployments widen the list via `allowed_hosts:`.
86+
DEFAULT_LOOPBACK_HOSTS = ["127.0.0.1", "::1", "localhost"].freeze
87+
5588
# Rack app interface. This transport can be mounted as a Rack app.
5689
def call(env)
5790
handle_request(Rack::Request.new(env))
5891
end
5992

6093
def handle_request(request)
94+
rebinding_error = validate_dns_rebinding(request)
95+
return rebinding_error if rebinding_error
96+
6197
case request.env["REQUEST_METHOD"]
6298
when "POST"
6399
handle_post(request)
@@ -808,6 +844,74 @@ def session_active?(session_id)
808844
active
809845
end
810846

847+
# Per MCP 2025-11-25, servers MUST validate the `Origin` header and SHOULD bind only to localhost
848+
# to prevent DNS rebinding attacks against locally bound MCP servers. Protection is on by default;
849+
# pass `dns_rebinding_protection: false` to disable it (e.g. when an upstream proxy or middleware already
850+
# performs the check). The `Host` header is validated against the loopback defaults plus `allowed_hosts:`,
851+
# and the `Origin` header, when present, must be same-origin or in `allowed_origins:`.
852+
def validate_dns_rebinding(request)
853+
return unless @dns_rebinding_protection
854+
855+
validate_host(request) || validate_origin(request)
856+
end
857+
858+
# Rejects a rebound `Host` (e.g. `evil.example.com` re-pointed at 127.0.0.1).
859+
# A request without a `Host` header (e.g. HTTP/1.0) is allowed; the rebinding vector this guards against always carries one.
860+
def validate_host(request)
861+
host = request.env["HTTP_HOST"]
862+
return if host.nil?
863+
864+
# An `allowed_hosts:` entry matches either the bare host name (any port)
865+
# or the full `host:port` value, so both `"app.example.com"` and
866+
# `"app.example.com:8443"` can be configured.
867+
normalized = host.downcase
868+
return if @allowed_hosts.include?(request_hostname(normalized)) || @allowed_hosts.include?(normalized)
869+
870+
forbidden_response("Forbidden: Invalid Host header")
871+
end
872+
873+
# A request without an `Origin` header (typical for non-browser MCP clients) is allowed. A browser cross-origin request is
874+
# rejected unless the origin is same-origin or explicitly allow-listed via `allowed_origins:`.
875+
def validate_origin(request)
876+
origin = request.env["HTTP_ORIGIN"]
877+
return if origin.nil?
878+
return if same_origin?(origin, request)
879+
return if @allowed_origins.include?(origin.downcase)
880+
881+
forbidden_response("Forbidden: Invalid Origin header")
882+
end
883+
884+
# Extracts the host name from a `Host` header value, stripping any port and IPv6 brackets
885+
# (`[::1]:8080` becomes `::1`, `127.0.0.1:8080` becomes `127.0.0.1`).
886+
def request_hostname(host)
887+
return host[/\A\[([^\]]+)\]/, 1] if host.start_with?("[")
888+
889+
host.split(":").first
890+
end
891+
892+
# Compares the `Origin` authority (host:port) against the request's own `Host`.
893+
# Scheme is not compared (the `Host` header carries none, and `request.scheme` is unreliable behind proxies),
894+
# but the `Origin`'s scheme is used to drop a redundant default port (`:80` for http, `:443` for https) from
895+
# both sides so `http://example.com` matches `Host: example.com:80`. Comparison is case-insensitive.
896+
def same_origin?(origin, request)
897+
host = request.env["HTTP_HOST"]
898+
return false if host.nil?
899+
900+
normalized = origin.downcase
901+
default_port = normalized.start_with?("https://") ? ":443" : ":80"
902+
authority = normalized.sub(%r{\Ahttps?://}, "")
903+
904+
authority.delete_suffix(default_port) == host.downcase.delete_suffix(default_port)
905+
end
906+
907+
def forbidden_response(message)
908+
json_rpc_error_response(
909+
status: 403,
910+
code: JsonRpcHandler::ErrorCode::INVALID_REQUEST,
911+
message: message,
912+
)
913+
end
914+
811915
def method_not_allowed_response
812916
json_rpc_error_response(
813917
status: 405,

0 commit comments

Comments
 (0)