ruby-sdk/lib/mcp/client/oauth/discovery.rb
Koichi ITO aaf0c71995 Restrict OAuth Discovery URL Destinations
## Motivation and Context

The OAuth client checked the scheme of every discovery URL but never checked where it pointed.
`Discovery.secure_url?` returns true for any `https` URL regardless of host, and the `resource_metadata` URL from
a `WWW-Authenticate` challenge is placed first in the candidate list, so a server answering
`401 Bearer resource_metadata="https://169.254.169.254/..."` had the client issue that request during discovery,
before the user approves anything. The same gap applied one hop deeper: a server that serves its own
Protected Resource Metadata can name any host in `authorization_servers`, and the endpoints in the document fetched
from there receive the dynamic client registration and the token request that carries the client credentials.

Two rules now apply to those URLs. Both run before the request is sent, and neither is configurable.

The `resource_metadata` URL must be on the MCP server's own origin. Protected Resource Metadata describes that server,
so a real deployment publishes it there, and requiring it means a challenge cannot aim the first request of the flow at
an unrelated host. RFC 9728 does not itself require this, so the SDK is deliberately stricter than the specification:
the range check below cannot recognize an internal host that is named rather than addressed, and this is the one URL
a server supplies directly.

The PRM `authorization_servers` entry and the `authorization_endpoint`, `token_endpoint`, and `registration_endpoint`
from Authorization Server metadata must not be IP literals in a private, loopback, link-local, or unique-local range,
which is the precaution RFC 9728 Section 7.7 asks clients to take. That check is skipped when the configured MCP server URL
is itself on such an address. Pointing a client at a private network is a deliberate act and its authorization server
usually lives on the same network, so `http://localhost` development and deployments that never leave a corporate network
keep working. Only a server reachable on the public internet is barred from steering the client inward.

A range check is only as good as its reading of the address, so the second rule also refuses hosts 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 reading only the dotted-decimal spelling would refuse
`169.254.169.254` while admitting every alias of it. Those hosts are refused outright rather than decoded and
range-checked, because their 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`. Choosing a reading here would leave
whichever one was not taken as a way through, and nothing is legitimately addressed in this notation.

Hostnames are not resolved. A lookup here would be a second network request driven by the same untrusted input,
and the address it returned need not be the one the HTTP client connects to a moment later, so the check would read as
a guarantee it cannot make. `MCP::Client::OAuth::Flow` also documents why its Faraday connection has no redirect-following
middleware: every check runs against the URL as written, so a connection that follows a `3xx` on its own would reach hosts
these rules just refused.

## How Has This Been Tested?

- New unit tests in `test/mcp/client/oauth/discovery_test.rb` cover `same_origin?` (case-insensitive scheme and host,
  explicit default ports, differing port/host/scheme, subdomains, malformed and hostless input) and `private_network_host?`
  (every blocked range, IPv4-mapped IPv6 spellings, bracketed IPv6, `localhost` matched exactly, and public addresses and names).
  One test documents that a named internal host reads as public, which is the deliberate limit of the predicate.
- Further unit tests cover the numeric spellings: packed integers, hexadecimal, and short forms that reach a blocked range,
  and the ambiguous leading-zero forms whose value depends on the resolver. Removing the rule leaves them addressable,
  which is what those tests fail on.
- New tests in `test/mcp/client/oauth/flow_test.rb` and `test/mcp/client/oauth/http_oauth_test.rb` drive both rules through `run!`,
  `refresh!`, and the transport. They assert with `assert_not_requested` that no request was issued rather than only that an error
  was raised, since nothing downstream can un-send one. One of them names the authorization server `https://0xa9fea9fe`, which
  without the rule above is fetched. A further test runs a complete flow with the MCP server and the authorization server on
  two loopback ports, which is what the carve-out exists for.
- `rake conformance` passes with all 22 auth scenarios green. Its harness runs the MCP server and the authorization server on
  separate loopback ports and publishes PRM on the server's own origin, so it exercises exactly the shapes these rules must not break.

## Breaking Changes

A client whose MCP server is reachable on the public internet no longer follows a `resource_metadata` URL on a different origin,
and no longer contacts an authorization server or OAuth endpoint at a private IP literal or at a host written in the numeric
IPv4 notation above. All were previously allowed. Local development, private-network deployments, and the conformance suite
are unaffected.
2026-08-13 14:54:27 +09:00

602 lines
30 KiB
Ruby

# frozen_string_literal: true
require "ipaddr"
require "uri"
module MCP
class Client
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 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
# the Bearer challenge parameters (`resource_metadata`, `scope`, `error`,
# ...) out of a header that may carry multiple challenges per RFC 7235
# and may use `quoted-pair` escapes per RFC 7230 Section 3.2.6.
# - **Discovery URL builders** (`protected_resource_metadata_urls`,
# `authorization_server_metadata_urls`): list the candidate well-known
# URLs to probe when no explicit metadata URL is supplied,
# in the priority order required by RFC 9728 and RFC 8414.
# - **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,
# and drops userinfo so credentials never reach the RFC 8707 `resource` claim
# or any error message.
# - **Resource coverage** (`resource_covers?`): decides whether a PRM `resource` URI
# is allowed to govern a given MCP server URL, i.e. whether the MCP endpoint sits
# "under" the resource per RFC 8707 audience semantics.
#
# Every entry point is a class method so it can be called from initializers and
# from any thread without synchronization.
module Discovery
# Matches a single `key=value` pair inside an HTTP auth-scheme challenge.
# `value` is either a quoted string (which can contain commas and spaces)
# or a bare token, per RFC 7235.
WWW_AUTH_PARAM_PATTERN = /\A([A-Za-z0-9_-]+)\s*=\s*(?:"((?:[^"\\]|\\.)*)"|([^\s,]+))/.freeze
class << self
# Parses a `WWW-Authenticate` header and returns the parameters of
# the `Bearer` challenge as a hash with lower-cased keys (e.g. `resource_metadata`,
# `scope`, `error`). Returns `{}` when no Bearer challenge is present.
# Handles multiple challenges (e.g. `Basic ..., Bearer ...` or `Bearer ..., DPoP ...`)
# by extracting only the Bearer parameters.
#
# - https://www.rfc-editor.org/rfc/rfc9728.html#section-5.1
# - https://www.rfc-editor.org/rfc/rfc7235.html#section-4.1
def parse_www_authenticate(header)
return {} unless header
# Locate the Bearer challenge: at the start of the header or after a comma.
bearer = header.match(/(?:\A|,)\s*Bearer(?:\s+|\z)/i)
return {} unless bearer
# Walk key=value pairs starting where Bearer's parameters begin.
# The loop stops at the first token that is not a key=value pair,
# which marks the next challenge (e.g. `, DPoP algs="..."`).
cursor = bearer.end(0)
params = {}
while cursor < header.length
prefix = header[cursor..]
prefix = prefix.sub(/\A\s*,?\s*/, "")
break if prefix.empty?
match = prefix.match(WWW_AUTH_PARAM_PATTERN)
break unless match
params[match[1].downcase] = match[2] ? unescape_quoted_pair(match[2]) : match[3]
cursor = header.length - prefix.length + match.end(0)
end
params
end
# Returns the candidate Protected Resource Metadata URLs to probe, in priority order.
# https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization#protected-resource-metadata-discovery-requirements
def protected_resource_metadata_urls(server_url:, resource_metadata_url: nil)
urls = []
urls << resource_metadata_url if resource_metadata_url
uri = URI.parse(server_url)
path = uri.path == "/" ? "" : uri.path.to_s
base = base_url(uri)
urls << "#{base}/.well-known/oauth-protected-resource#{path}"
urls << "#{base}/.well-known/oauth-protected-resource"
urls.uniq
end
# Returns the candidate Authorization Server metadata URLs to probe, in priority order.
#
# Per SEP-2351, MCP uses the default `oauth-authorization-server` well-known URI suffix
# registered by RFC 8414 Section 7.3 and defines no application-specific suffix of its own.
# The OAuth candidates below therefore use only that default suffix
# (plus the `openid-configuration` suffix from OpenID Connect Discovery),
# both in the RFC 8414 Section 3.1 path-inserted form for issuers with a path component
# and in the root form for issuers without one.
#
# - https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization#authorization-server-metadata-discovery
# - https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2351
# - https://www.rfc-editor.org/rfc/rfc8414#section-3.1
def authorization_server_metadata_urls(issuer_url)
uri = URI.parse(issuer_url)
path = uri.path == "/" ? "" : uri.path.to_s
base = base_url(uri)
if path.empty?
["#{base}/.well-known/oauth-authorization-server", "#{base}/.well-known/openid-configuration"]
else
[
"#{base}/.well-known/oauth-authorization-server#{path}",
"#{base}/.well-known/openid-configuration#{path}",
"#{base}#{path}/.well-known/openid-configuration",
]
end
end
# Returns a canonical form of `url` suitable for comparing two URIs
# that are meant to identify the same protected resource: lowercased scheme/host,
# default port stripped, fragment removed, percent-encoded dot octets normalized
# to `.` per RFC 3986 Section 6.2.2.2, dot-segments in the path resolved per
# RFC 3986 Section 5.2.4, and a single trailing `/` on the root path normalized away.
#
# Userinfo is *dropped*. The MCP authorization spec sends the canonicalized URL
# on the wire as the RFC 8707 `resource` claim and surfaces it in error messages;
# both paths would leak `user:pass@` credentials to the authorization server and
# to log destinations if we preserved them. The MCP server URI does not legitimately
# carry userinfo, so dropping it is also a no-op for normal traffic.
#
# Decoding `%2e`/`%2E` *before* dot-segment resolution is what prevents
# an attacker-supplied URL like `https://srv.example.com/api/%2e%2e/mcp` from sneaking
# past the PRM `resource` check in `resource_covers?`.
def canonicalize_url(url)
uri = URI.parse(url.to_s)
uri.fragment = nil
# `URI::Generic#userinfo=` is a no-op on Ruby 2.7 (the project's minimum supported version),
# so clear the components individually.
if uri.respond_to?(:user) && (uri.user || uri.password)
uri.user = nil
uri.password = nil
end
uri.scheme = uri.scheme.downcase if uri.scheme
uri.host = uri.host.downcase if uri.host
uri.port = nil if uri.port == uri.default_port
path = uri.path.to_s.gsub(/%2[eE]/, ".")
uri.path = remove_dot_segments(path)
uri.path = "" if uri.path == "/"
uri.query = normalize_query(uri.query)
uri.to_s
end
# Returns true when `url` is safe to use for OAuth communication per
# the MCP authorization spec's "Communication Security" requirement:
# `https` is always allowed, `http` is permitted only when the host is
# a loopback address (`localhost`, `127.0.0.0/8`, or `::1`).
#
# The loopback exception applies uniformly to every OAuth-related URL
# the SDK consumes (PRM URL, AS metadata URL, `authorization_servers`
# entries, `authorization_endpoint`, `token_endpoint`, `registration_endpoint`,
# the `redirect_uri`, and the MCP transport URL when `oauth:` is set).
# A strict reading of OAuth 2.1 reserves the loopback carve-out for
# `redirect_uri` only (per RFC 8252), but neither the Python nor
# the TypeScript MCP SDK enforces HTTPS on those endpoints either -
# and the official MCP conformance test suite drives its fixtures
# over `http://localhost` auth servers, so enforcing HTTPS for everything
# except `redirect_uri` would break local development out of the box and
# regress 16 conformance scenarios. Operators who run in production are
# expected to deploy real HTTPS endpoints; this helper does not enforce
# that at the SDK boundary.
#
# Rejects URLs that fail to parse, lack a host, or whose `http://` host is
# something like `127.attacker.com` or `foo.localhost`,
# which would otherwise pass a naive `start_with?("127.")` check.
# https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization#communication-security
def secure_url?(url)
return false if url.nil? || url.to_s.empty?
uri = URI.parse(url.to_s)
return false if uri.host.nil? || uri.host.empty?
scheme = uri.scheme&.downcase
return true if scheme == "https"
return loopback_host?(uri.host) if scheme == "http"
false
rescue URI::InvalidURIError
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`.
#
# Spec-required:
#
# - scheme MUST be `https` (the loopback-`http` carve-out used for discovery does not apply:
# the document URL is sent verbatim to the authorization server as the OAuth `client_id`
# and travels off-loopback)
# - host MUST be present
# - path MUST be non-empty and MUST NOT be the root (`/`); the document is a discrete resource,
# not the origin
# - URL MUST NOT carry a fragment or userinfo: a fragment is not sent to the server, and userinfo
# would leak credentials into every `client_id` log line
# - path MUST be already free of `.` / `..` dot segments after percent-decoding, so two URLs with
# the same effective path do not produce different `client_id` strings
#
# SDK policy (stricter than the draft):
#
# - URL MUST NOT carry a query string. The draft marks query components only SHOULD NOT include,
# but different encodings of the same query (`?a=1&b=2` vs `?b=2&a=1`) would yield distinct
# `client_id` values for the same logical document.
def client_id_metadata_document_url?(url)
return false if url.nil? || url.to_s.empty?
uri = URI.parse(url.to_s)
return false unless uri.scheme&.downcase == "https"
return false if uri.host.nil? || uri.host.empty?
return false unless uri.fragment.nil?
return false unless uri.query.nil?
return false if uri.respond_to?(:user) && (uri.user || uri.password)
path = uri.path.to_s
return false if path.empty? || path == "/"
decoded = path.gsub(/%2[eE]/, ".")
segments = decoded.split("/", -1)
return false if segments.any? { |segment| segment == "." || segment == ".." }
true
rescue URI::InvalidURIError
false
end
# Infers the OIDC Dynamic Client Registration `application_type` for a client from its `redirect_uris`.
# Per SEP-837, MCP clients MUST specify an appropriate application type during Dynamic Client Registration
# so the authorization server can apply the matching redirect URI policy.
#
# Returns `"native"` when every redirect URI is a native-app URI: a custom non-http(s) scheme (RFC 8252 Section 7.1)
# or an http(s) URI whose host is a loopback address (`localhost`, `127.0.0.0/8`, or `::1`, RFC 8252 Section 7.3).
# Returns `"web"` otherwise, including when `redirect_uris` is nil, empty, or contains an unparseable URI.
#
# - https://github.com/modelcontextprotocol/modelcontextprotocol/pull/837
# - https://openid.net/specs/openid-connect-registration-1_0.html#ClientMetadata
def infer_application_type(redirect_uris)
uris = Array(redirect_uris)
return "web" if uris.empty?
uris.all? { |uri| native_redirect_uri?(uri) } ? "native" : "web"
end
# Like `canonicalize_url` but also strips query string, fragment, and
# userinfo. This variant is used for identity comparison against
# the request URL Faraday actually sends, which differs from the value
# the caller passed in two ways: `Faraday::Connection#url_prefix`
# drops query parameters, and Faraday hoists `user:pass@` out of
# the URL into an `Authorization: Basic` header before the request goes
# out. Including userinfo here would (a) raise a false-positive
# `InsecureURLError` on any legitimate URL with credentials in
# the authority, and (b) leak `user:pass` through the resulting error
# message - both of which would defeat the bearer-token-protection
# purpose of the identity check.
def canonicalize_origin_and_path(url)
uri = URI.parse(url.to_s)
uri.fragment = nil
uri.query = nil
# `URI::Generic#userinfo=` is a no-op on Ruby 2.7 (the project's minimum supported version),
# so clear the components individually.
if uri.respond_to?(:user) && (uri.user || uri.password)
uri.user = nil
uri.password = nil
end
uri.scheme = uri.scheme.downcase if uri.scheme
uri.host = uri.host.downcase if uri.host
uri.port = nil if uri.port == uri.default_port
path = uri.path.to_s.gsub(/%2[eE]/, ".")
uri.path = remove_dot_segments(path)
uri.path = "" if uri.path == "/"
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.
#
# 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:)
prm_uri = URI.parse(prm)
server_uri = URI.parse(server)
return false unless prm_uri.scheme == server_uri.scheme &&
prm_uri.host == server_uri.host &&
prm_uri.port == server_uri.port
prm_path = prm_uri.path.to_s
server_path = server_uri.path.to_s
prm_path = "" if prm_path == "/"
server_path = "" if server_path == "/"
path_covers = server_path == prm_path || server_path.start_with?("#{prm_path}/")
return false unless path_covers
prm_query = prm_uri.query
return true if prm_query.nil?
prm_query == server_uri.query
end
private
# Unescapes a `quoted-string` value's `quoted-pair` octets per
# RFC 7230 Section 3.2.6 (referenced from RFC 7235): `\<char>` becomes `<char>`.
# https://www.rfc-editor.org/rfc/rfc7230#section-3.2.6
def unescape_quoted_pair(value)
value.gsub(/\\(.)/, '\1')
end
# Recognizes the IPv4 loopback range (`127.0.0.0/8`), IPv6 loopback
# (`::1`, optionally bracketed by `URI.parse`), and the `localhost`
# hostname (matched exactly so that hostnames like `foo.localhost` or
# `127.attacker.com` are not treated as loopback).
IPV4_LOOPBACK_RANGE = IPAddr.new("127.0.0.0/8")
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?
normalized = host.downcase
return true if normalized == "localhost"
ip_candidate = normalized.delete_prefix("[").delete_suffix("]")
address = parse_ip_address(ip_candidate)
return false unless address
return IPV4_LOOPBACK_RANGE.include?(address) if address.ipv4?
return address == IPV6_LOOPBACK if address.ipv6?
false
end
def parse_ip_address(candidate)
IPAddr.new(candidate)
rescue IPAddr::Error
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.
def native_redirect_uri?(url)
uri = URI.parse(url.to_s)
scheme = uri.scheme&.downcase
return false if scheme.nil?
return loopback_host?(uri.host) if ["http", "https"].include?(scheme)
true
rescue URI::InvalidURIError
false
end
def base_url(uri)
port_part = uri.port && uri.port != uri.default_port ? ":#{uri.port}" : ""
"#{uri.scheme}://#{uri.host}#{port_part}"
end
# Normalizes a URL query string so two URLs that are equivalent in
# OAuth identity terms compare as equal. This is required because
# Faraday transparently rewrites `env.url` before sending a request:
#
# - Parameters get sorted by name (`?b=2&a=1` -> `?a=1&b=2`).
# - Percent-encoded hex is uppercased (`?x=%2f` -> `?x=%2F`).
# - A trailing `?` with no parameters is dropped (`?` -> no query).
# - Same-name keys are collapsed so only the last value survives
# (`?a=1&a=2` -> `?a=2`).
# - Empty-name pairs and blank `&&` separators are dropped
# (`?=v` -> no query, `?&&a=1&&` -> `?a=1`).
# - Value-less keys (`?tenant`) and empty-value keys (`?tenant=`)
# are kept distinct -- Faraday preserves the `=` exactly as
# the caller passed it. `URI.decode_www_form` / `encode_www_form`
# would collapse both to `?tenant=`, so this function does
# the parsing by hand on `&`-separated segments.
#
# Without applying the same transformation to our snapshotted URL,
# the request-time URL guard would false-positive on every URL that
# falls under one of the rules above.
#
# Returns `nil` when the resulting query is empty
# (matching Faraday's drop-empty-query behavior).
def normalize_query(query)
return if query.nil? || query.empty?
# Each segment becomes `[decoded_name, has_equals?, decoded_value_or_nil]`.
# `has_equals?` is what lets us preserve the `?key` vs `?key=`
# distinction that Faraday respects.
parsed = query.split("&").filter_map do |segment|
next if segment.empty?
name_raw, separator, value_raw = segment.partition("=")
name = URI.decode_www_form_component(name_raw)
next if name.empty?
has_equals = !separator.empty?
value = has_equals ? URI.decode_www_form_component(value_raw) : nil
[name, has_equals, value]
end
# Keys ending in `[]` (`tenant[]`, `roles[]`) are Faraday's array
# notation: the encoder preserves every occurrence in input order
# instead of collapsing them. All other keys (`tenant`, `a[b]`,
# plain scalars) are collapsed with last-write-wins semantics.
# Separating the two avoids a false negative where a hijacked
# middleware drops an entry from a `?tenant[]=victim&tenant[]=...`
# URL and slips past the guard, and a false positive on
# a legitimate `?roles[]=a&roles[]=b` URL.
array_segments = []
scalar_segments = {}
parsed.each do |name, has_equals, value|
if name.end_with?("[]")
array_segments << [name, has_equals, value]
else
scalar_segments[name] = [has_equals, value]
end
end
scalar_entries = scalar_segments.map { |name, (has_equals, value)| [name, has_equals, value] }
combined = scalar_entries + array_segments
return if combined.empty?
# Stable sort by name: array entries that share a name keep their
# original order, while scalar names are alphabetized to match
# Faraday's deterministic encoding order.
combined.each_with_index
.sort_by { |(name, _, _), index| [name, index] }
.map do |(name, has_equals, value), _index|
encoded_name = URI.encode_www_form_component(name)
if has_equals
"#{encoded_name}=#{URI.encode_www_form_component(value)}"
else
encoded_name
end
end.join("&")
end
# Implements RFC 3986 Section 5.2.4 `remove_dot_segments`. Walks the input
# buffer one segment at a time, popping the previous output segment
# whenever a `..` is encountered, so that `/api/../mcp` collapses to
# `/mcp` and `/foo/./bar` collapses to `/foo/bar`.
# https://www.rfc-editor.org/rfc/rfc3986#section-5.2.4
def remove_dot_segments(path)
return path if path.nil? || path.empty?
input = path.dup
output = +""
until input.empty?
if input.start_with?("../")
input = input[3..]
elsif input.start_with?("./")
input = input[2..]
elsif input.start_with?("/./")
input = "/#{input[3..]}"
elsif input == "/."
input = "/"
elsif input.start_with?("/../")
input = "/#{input[4..]}"
output = remove_last_segment(output)
elsif input == "/.."
input = "/"
output = remove_last_segment(output)
elsif input == "." || input == ".."
input = ""
else
segment = input.match(%r{\A/?[^/]*})[0]
output << segment
input = input[segment.length..]
end
end
output
end
def remove_last_segment(output)
idx = output.rindex("/")
return +"" if idx.nil?
output[0...idx]
end
end
end
end
end
end