@@ -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{\A https?://} , "" )
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