Merge commit from fork

Restrict OAuth Discovery URL Destinations
This commit is contained in:
Koichi ITO 2026-08-15 11:26:11 +09:00 • committed by GitHub
commit 84eac104f0
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 601 additions and 27 deletions

View file

@ -8,7 +8,7 @@ module MCP
module OAuth
# Stateless helpers that map MCP-authorization spec URLs and headers into something
# the `Flow` orchestrator and `MCP::Client::HTTP` transport can act on.
# The module bundles five concerns that share no state but are closely related to
# The module bundles six concerns that share no state but are closely related to
# the spec's "Discovery" and "Communication Security" sections:
#
# - **`WWW-Authenticate` parsing** (`parse_www_authenticate`): pulls
@ -22,6 +22,10 @@ module MCP
# - **Communication Security check** (`secure_url?`): enforces "HTTPS only"
# for every OAuth-facing URL, with the loopback carve-out described in
# `secure_url?`'s comment.
# - **Destination checks** (`same_origin?`, `private_network_host?`):
# answer *where* a URL points, which the scheme alone does not. `Flow` uses
# them to refuse server-supplied discovery URLs aimed at hosts the MCP server
# has no business steering the client toward.
# - **URL canonicalization** (`canonicalize_url`): normalizes scheme,
# host, port, path, percent-encoded dot segments, and fragments
# so two URLs that *refer to the same resource* compare as equal,
@ -193,6 +197,51 @@ module MCP
false
end
# Returns true when `url` and `other` share an origin: same scheme, same host,
# and same port. Scheme and host compare case-insensitively, and an implicit default port compares
# equal to the same port written out, because `URI::HTTP#port` already resolves to the default.
#
# Anything that fails to parse or carries no host returns false, so a caller using this as
# a security gate refuses rather than admits on malformed input.
def same_origin?(url, other)
uri = URI.parse(url.to_s)
other_uri = URI.parse(other.to_s)
return false if uri.host.nil? || uri.host.empty?
return false if other_uri.host.nil? || other_uri.host.empty?
uri.scheme&.downcase == other_uri.scheme&.downcase &&
uri.host.downcase == other_uri.host.downcase &&
uri.port == other_uri.port
rescue URI::InvalidURIError
false
end
# Returns true when `host` is an IP literal that is not reachable from the public internet,
# or the `localhost` name. The ranges are the ones RFC 9728 Section 7.7 and the MCP security best practices name
# as SSRF targets, so `169.254.0.0/16` covers the `169.254.169.254` cloud metadata address that motivates the check.
#
# Hostnames are deliberately NOT resolved. A lookup here would be a second network request driven by
# the same untrusted input, and the address it returned could differ from the one the HTTP client connects to
# a moment later, so the check would read as a guarantee it cannot make. That leaves names like `vault.corp.internal`
# out of reach of this predicate; it is one layer of an SSRF defense, not the whole of one.
def private_network_host?(host)
return false if host.nil? || host.empty?
normalized = host.downcase
return true if normalized == "localhost"
literal = normalized.delete_prefix("[").delete_suffix("]")
address = parse_ip_address(literal)
return numeric_ipv4_spelling?(literal) unless address
# `::ffff:169.254.169.254` addresses the same interface as `169.254.169.254`,
# so compare the IPv4 form rather than letting the mapped spelling through.
address = address.native if address.ipv6? && address.ipv4_mapped?
ranges = address.ipv4? ? PRIVATE_IPV4_RANGES : PRIVATE_IPV6_RANGES
ranges.any? { |range| range.include?(address) }
end
# Returns true when `url` satisfies the structural requirements for
# a Client ID Metadata Document URL per the MCP 2025-11-25
# authorization specification and `draft-ietf-oauth-client-id-metadata-document-00`.
@ -287,20 +336,17 @@ module MCP
uri.to_s
end
# Returns true when `prm` (a PRM `resource` URL) covers `server`
# (the MCP endpoint URL): same scheme/host/port, with PRM's path being
# a prefix of the server's path. When PRM also advertises a query
# string, the server's query MUST be identical to it
# (otherwise a hijacked PRM that advertises `?tenant=evil` would cover
# an MCP server at `?tenant=victim` and let the attacker mint
# a different tenant's token for the same origin + path).
# PRM with *no* query (URI#query returns `nil`) acts as a generic identifier
# over the origin + path prefix and covers any server query.
# Returns true when `prm` (a PRM `resource` URL) covers `server` (the MCP endpoint URL):
# same scheme/host/port, with PRM's path being a prefix of the server's path. When PRM
# also advertises a query string, the server's query MUST be identical to it (otherwise
# a hijacked PRM that advertises `?tenant=evil` would cover an MCP server at `?tenant=victim`
# and let the attacker mint a different tenant's token for the same origin + path).
# PRM with *no* query (URI#query returns `nil`) acts as a generic identifier over
# the origin + path prefix and covers any server query.
#
# An empty query (`prm_url?` -- URI#query returns `""`) is NOT
# treated as wildcard: it represents the URI literally `<...>?`,
# which is distinct from "no query at all" and from any non-empty query,
# so it must match exactly.
# An empty query (`prm_url?` -- URI#query returns `""`) is NOT treated as wildcard:
# it represents the URI literally `<...>?`, which is distinct from "no query at all"
# and from any non-empty query, so it must match exactly.
#
# Both arguments must already be canonicalized.
def resource_covers?(prm:, server:)
@ -340,6 +386,29 @@ module MCP
IPV6_LOOPBACK = IPAddr.new("::1")
private_constant :IPV4_LOOPBACK_RANGE, :IPV6_LOOPBACK
# Backing ranges for `private_network_host?`.
#
# `0.0.0.0/8` is "this network" and `100.64.0.0/10` is the carrier-grade NAT
# space; neither is a destination a legitimate authorization server publishes,
# and both are reachable enough from a client's network position to be worth
# refusing. `::/96` covers the unspecified address, IPv6 loopback, and
# the deprecated IPv4-compatible form (`::10.0.0.1`) in one range.
PRIVATE_IPV4_RANGES = [
IPAddr.new("0.0.0.0/8"),
IPAddr.new("10.0.0.0/8"),
IPAddr.new("100.64.0.0/10"),
IPAddr.new("127.0.0.0/8"),
IPAddr.new("169.254.0.0/16"),
IPAddr.new("172.16.0.0/12"),
IPAddr.new("192.168.0.0/16"),
].freeze
PRIVATE_IPV6_RANGES = [
IPAddr.new("::/96"),
IPAddr.new("fc00::/7"),
IPAddr.new("fe80::/10"),
].freeze
private_constant :PRIVATE_IPV4_RANGES, :PRIVATE_IPV6_RANGES
def loopback_host?(host)
return false if host.nil? || host.empty?
@ -362,6 +431,31 @@ module MCP
nil
end
# Matches one part of the `inet_aton` numeric grammar: hexadecimal (`0x7f`),
# octal (`0177`), or decimal (`127`).
INET_ATON_PART = /\A(?:0x\h+|0[0-7]*|[1-9]\d*)\z/.freeze
private_constant :INET_ATON_PART
# Returns true when `host` is written in the numeric IPv4 grammar `inet_aton` accepts
# and `IPAddr` rejects: one to four dot-separated parts, each hexadecimal, octal, or
# decimal, with the last part filling the remaining bytes. `2130706433`, `127.1`, and
# `0x7f000001` all reach 127.0.0.1, and `0xa9fea9fe` reaches the cloud metadata address,
# so a check that understood only the canonical form would refuse `169.254.169.254`
# while waving through every other spelling of it.
#
# Such a host is refused outright rather than decoded and range-checked, because its
# value is resolver-dependent: a leading zero reads as octal on one platform and decimal
# on another, which is the difference between 8.0.0.1 and 10.0.0.1 for `010.0.0.1`.
# Deciding here which one it meant would leave whichever reading was not taken as a way
# through. No authorization server is legitimately addressed in this notation, so
# refusing the grammar entirely costs nothing and leaves no spelling to miss.
def numeric_ipv4_spelling?(host)
parts = host.split(".", -1)
return false if parts.empty? || parts.length > 4
parts.all? { |part| INET_ATON_PART.match?(part) }
end
# A redirect URI counts as native when it uses a custom non-http(s) scheme
# (e.g. `com.example.app:/callback`) or when it is an http(s) URI whose host is
# a loopback address. A URI without a scheme or one that fails to parse is not native.

View file

@ -33,9 +33,15 @@ module MCP
def run!(server_url:, resource_metadata_url: nil, scope: nil)
# The `resource_metadata` URL ships in `WWW-Authenticate` and is the very
# first thing we contact in the OAuth flow, so it has to clear the same
# Communication Security bar as the OAuth endpoints downstream.
# Communication Security bar as the OAuth endpoints downstream, and it has to
# point back at the server that issued the challenge.
if resource_metadata_url
ensure_secure_url!(resource_metadata_url, label: "WWW-Authenticate resource_metadata URL")
ensure_same_origin!(
resource_metadata_url,
label: "WWW-Authenticate resource_metadata URL",
server_url: server_url,
)
end
prm, authorization_server = locate_authorization_server(
@ -49,7 +55,11 @@ module MCP
# being redirected to credentials minted for a different audience.
resource = canonical_resource(server_url: server_url, prm_resource: prm&.dig("resource"))
as_metadata = authorization_server_metadata(authorization_server: authorization_server, legacy: prm.nil?)
as_metadata = authorization_server_metadata(
authorization_server: authorization_server,
legacy: prm.nil?,
server_url: server_url,
)
case provider_authorization_flow
when :client_credentials
@ -212,6 +222,11 @@ module MCP
if resource_metadata_url
ensure_secure_url!(resource_metadata_url, label: "WWW-Authenticate resource_metadata URL")
ensure_same_origin!(
resource_metadata_url,
label: "WWW-Authenticate resource_metadata URL",
server_url: server_url,
)
end
prm, authorization_server = locate_authorization_server(
@ -221,7 +236,11 @@ module MCP
resource = canonical_resource(server_url: server_url, prm_resource: prm&.dig("resource"))
as_metadata = authorization_server_metadata(authorization_server: authorization_server, legacy: prm.nil?)
as_metadata = authorization_server_metadata(
authorization_server: authorization_server,
legacy: prm.nil?,
server_url: server_url,
)
client_info = if have_stored_client_info
# Pre-registered / DCR-issued `client_information` always wins: if the user picked an explicit identity,
@ -296,6 +315,11 @@ module MCP
if prm
authorization_server = first_authorization_server(prm)
ensure_secure_url!(authorization_server, label: "PRM `authorization_servers` entry")
ensure_routable_destination!(
authorization_server,
label: "PRM `authorization_servers` entry",
server_url: server_url,
)
[prm, authorization_server]
else
authorization_base = server_origin!(server_url)
@ -311,7 +335,7 @@ module MCP
# and a pre-PRM server may host its OAuth endpoints under a path prefix whose `issuer` legitimately differs from
# the origin the metadata was discovered at (neither the TypeScript nor the Python SDK validates the issuer on this path).
# When even the metadata document is absent, the legacy spec's default endpoints are used.
def authorization_server_metadata(authorization_server:, legacy:)
def authorization_server_metadata(authorization_server:, legacy:, server_url:)
metadata = if legacy
begin
fetch_authorization_server_metadata(issuer_url: authorization_server)
@ -324,7 +348,7 @@ module MCP
end
end
ensure_secure_endpoints!(metadata)
ensure_secure_endpoints!(metadata, server_url: server_url)
metadata
end
@ -445,13 +469,72 @@ module MCP
"#{label} #{url.inspect} is not over HTTPS; refusing to use it (MCP authorization Communication Security)."
end
def ensure_secure_endpoints!(as_metadata)
# Requires a URL the *server* chose to sit on the origin the *caller* chose.
#
# Protected Resource Metadata describes the MCP server itself, so on a real deployment it is
# published on that server's own origin. Without this check a `WWW-Authenticate` challenge
# can aim the first request of the flow at any host the client can route to: the URL arrives
# from the network, it is fetched before the user approves anything, and the `resource` check that
# runs afterwards cannot un-send the request.
#
# RFC 9728 does not itself require the metadata URL to be same-origin, so this is stricter than
# the specification. It is enforced unconditionally because no known deployment publishes its PRM
# anywhere else, and because the alternative (`Discovery.private_network_host?`) cannot see
# internal hosts that are named rather than addressed.
# https://www.rfc-editor.org/rfc/rfc9728#section-7.7
def ensure_same_origin!(url, label:, server_url:)
return if Discovery.same_origin?(url, server_url)
raise AuthorizationError,
"#{label} #{sanitized_url(url).inspect} is not on the MCP server origin " \
"#{sanitized_url(server_url).inspect}; refusing to fetch it."
end
# Refuses an OAuth URL that points into a private, loopback, link-local, or unique-local address,
# which is the SSRF precaution RFC 9728 Section 7.7 and the MCP security best practices ask clients to take.
#
# The carve-out matters as much as the rule: when the MCP server the caller configured is itself on such an address,
# the whole flow is already inside that network and the authorization server legitimately lives there too.
# That covers `http://localhost` development, the conformance harness (which runs the MCP server and
# the authorization server on two loopback ports), and deployments that never leave a corporate network.
# Only a server reachable on the public internet is barred from steering the client inward.
# https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization
def ensure_routable_destination!(url, label:, server_url:)
return unless private_network_url?(url)
return if private_network_url?(server_url)
raise AuthorizationError,
"#{label} #{sanitized_url(url).inspect} points into a private network range, " \
"which the MCP server at #{sanitized_url(server_url).inspect} is not on; refusing to contact it."
end
def ensure_secure_endpoints!(as_metadata, server_url:)
["authorization_endpoint", "token_endpoint", "registration_endpoint"].each do |key|
endpoint = as_metadata[key]
ensure_secure_url!(endpoint, label: "Authorization server #{key}") if endpoint
next unless endpoint
ensure_secure_url!(endpoint, label: "Authorization server #{key}")
ensure_routable_destination!(endpoint, label: "Authorization server #{key}", server_url: server_url)
end
end
# `ensure_secure_url!` runs first at every call site and already rejects a URL that fails to parse,
# so the rescue here is a backstop rather than a leniency.
def private_network_url?(url)
Discovery.private_network_host?(URI.parse(url.to_s).host)
rescue URI::InvalidURIError
false
end
# Strips userinfo and query before a URL reaches an exception message, the same precaution `MCP::Client::HTTP` takes
# when it reports a URL: these values come off the network and can carry credentials that would otherwise land in
# every log destination the error passes through.
def sanitized_url(url)
Discovery.canonicalize_origin_and_path(url)
rescue URI::Error
url.to_s
end
# Per RFC 8414 Section 3.3, the AS metadata document's `issuer` value MUST be
# identical (literal byte-for-byte equality, no normalization) to
# the issuer URL the client used to discover that document. This guards
@ -962,6 +1045,11 @@ module MCP
@http_client ||= @http_client_factory.call
end
# Deliberately built without redirect-following middleware. Every destination check in
# this class runs against the URL as written, before the request goes out, so a connection
# that transparently followed a `3xx` would let a server reach a host the checks just refused.
# A caller passing `http_client_factory:` takes on that responsibility: add redirect following here
# and the guards above only cover the first hop.
def default_http_client
require "faraday"
Faraday.new do |faraday|