Merge commit from fork
Restrict OAuth Discovery URL Destinations
This commit is contained in:
commit
84eac104f0
2 changed files with 601 additions and 27 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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|
|
||||
|
|
|
|||
Loading…
Reference in a new issue