## Motivation and Context `Flow#locate_authorization_server` treated every failure to fetch Protected Resource Metadata as "the server publishes none" and took the legacy 2025-03-26 path, where the MCP server's origin becomes the authorization base URL. A request that never reached the server, or a `5xx` or `429`, says nothing about what the server publishes, so a transient failure moved the flow to a different authorization server, discarded client information and tokens bound to the real one on the way, and surfaced as an unrelated error, such as a failed registration at the origin, rather than as the fetch failure itself. The comment claimed both reference SDKs behave that way, which is not so: outside browsers the TypeScript SDK propagates network errors from discovery, and the Python SDK propagates them and refuses the legacy path after a `5xx` or `429` from any candidate. Discovery now keeps trying candidate URLs until one serves a JSON object and classifies the outcome once they are exhausted. Like the Python SDK, it remembers a `5xx` or `429` from any candidate before deciding on the legacy path; moving on to the next candidate after a network error is what the loop already did. `MetadataNotPublishedError` (a `4xx` other than `429`, or a body that is not a JSON object) is the only failure that selects the legacy path; `MetadataUnreachableError` (a network error, or a `5xx` or `429`, from any candidate) is raised instead. Both subclass `AuthorizationError`, so callers rescuing that class keep working, and `MCP::Client::HTTP` still falls back from a failed refresh to the full flow, which repeats discovery and raises the same way unless the server has recovered. A body over the response cap is refused outright with a plain `AuthorizationError`, as it already was on the modern path. The message lists every candidate's failure, with the URL stripped of userinfo, query and fragment and cut to a fixed length, but otherwise spelled as requested, and with transport and parser messages bounded, as token endpoint diagnostics already are. The default legacy endpoints for an origin whose own metadata document is absent are unchanged. ## How Has This Been Tested? New tests in `test/mcp/client/oauth/flow_test.rb` and `test/mcp/client/oauth/discovery_test.rb`. Against the library before this change, discovery in the scenarios these tests set up took the legacy path instead of raising. ## Breaking Changes When no Protected Resource Metadata candidate serves a JSON object, a request that failed to reach the server, or returned a `5xx` or `429`, now raises instead of attempting the legacy 2025-03-26 discovery at the MCP server's origin, and a Protected Resource Metadata body over the response cap is refused instead of triggering that fallback.
727 lines
35 KiB
Ruby
727 lines
35 KiB
Ruby
# frozen_string_literal: true
|
|
|
|
require "test_helper"
|
|
require "mcp/client/oauth/discovery"
|
|
|
|
module MCP
|
|
class Client
|
|
module OAuth
|
|
class DiscoveryTest < Minitest::Test
|
|
def test_parse_www_authenticate_with_quoted_resource_metadata
|
|
header = 'Bearer error="invalid_token", error_description="Missing", ' \
|
|
'resource_metadata="https://srv.example.com/.well-known/oauth-protected-resource/mcp"'
|
|
params = Discovery.parse_www_authenticate(header)
|
|
|
|
assert_equal("invalid_token", params["error"])
|
|
assert_equal("Missing", params["error_description"])
|
|
assert_equal("https://srv.example.com/.well-known/oauth-protected-resource/mcp", params["resource_metadata"])
|
|
end
|
|
|
|
def test_parse_www_authenticate_returns_empty_for_other_schemes
|
|
assert_empty(Discovery.parse_www_authenticate('Basic realm="x"'))
|
|
end
|
|
|
|
def test_parse_www_authenticate_returns_empty_for_nil
|
|
assert_empty(Discovery.parse_www_authenticate(nil))
|
|
end
|
|
|
|
def test_parse_www_authenticate_finds_bearer_after_other_scheme
|
|
header = 'Basic realm="x", Bearer error="invalid_token", resource_metadata="https://srv/x"'
|
|
params = Discovery.parse_www_authenticate(header)
|
|
|
|
assert_equal("invalid_token", params["error"])
|
|
assert_equal("https://srv/x", params["resource_metadata"])
|
|
end
|
|
|
|
def test_parse_www_authenticate_stops_at_following_scheme
|
|
header = 'Bearer error="invalid_token", resource_metadata="https://srv/x", DPoP algs="ES256"'
|
|
params = Discovery.parse_www_authenticate(header)
|
|
|
|
assert_equal("invalid_token", params["error"])
|
|
assert_equal("https://srv/x", params["resource_metadata"])
|
|
refute(params.key?("algs"))
|
|
end
|
|
|
|
def test_parse_www_authenticate_returns_empty_when_bearer_absent
|
|
assert_empty(Discovery.parse_www_authenticate('DPoP algs="ES256"'))
|
|
end
|
|
|
|
def test_parse_www_authenticate_handles_bare_bearer
|
|
assert_empty(Discovery.parse_www_authenticate("Bearer"))
|
|
end
|
|
|
|
def test_parse_www_authenticate_handles_tab_separator
|
|
params = Discovery.parse_www_authenticate(%(Bearer\terror="invalid_token",\tresource_metadata="https://srv/x"))
|
|
|
|
assert_equal("invalid_token", params["error"])
|
|
assert_equal("https://srv/x", params["resource_metadata"])
|
|
end
|
|
|
|
def test_parse_www_authenticate_tolerates_trailing_comma
|
|
params = Discovery.parse_www_authenticate('Bearer error="invalid_token", resource_metadata="https://srv/x",')
|
|
|
|
assert_equal("invalid_token", params["error"])
|
|
assert_equal("https://srv/x", params["resource_metadata"])
|
|
end
|
|
|
|
def test_parse_www_authenticate_handles_quoted_value_with_comma_and_spaces
|
|
params = Discovery.parse_www_authenticate('Bearer error_description="missing, expired, or revoked token"')
|
|
|
|
assert_equal("missing, expired, or revoked token", params["error_description"])
|
|
end
|
|
|
|
def test_parse_www_authenticate_unescapes_quoted_pair
|
|
# Per RFC 7230 Section 3.2.6, a backslash inside a quoted-string escapes
|
|
# the next character. The parser must surface the unescaped form.
|
|
header = 'Bearer error_description="value with \"quoted\" word and a back\\\\slash"'
|
|
params = Discovery.parse_www_authenticate(header)
|
|
|
|
assert_equal('value with "quoted" word and a back\\slash', params["error_description"])
|
|
end
|
|
|
|
def test_protected_resource_metadata_urls_uses_explicit_url_first
|
|
urls = Discovery.protected_resource_metadata_urls(
|
|
server_url: "https://api.example.com/mcp",
|
|
resource_metadata_url: "https://api.example.com/.well-known/oauth-protected-resource/mcp",
|
|
)
|
|
|
|
assert_equal("https://api.example.com/.well-known/oauth-protected-resource/mcp", urls.first)
|
|
assert_includes(urls, "https://api.example.com/.well-known/oauth-protected-resource")
|
|
end
|
|
|
|
def test_protected_resource_metadata_urls_falls_back_to_path_then_root
|
|
urls = Discovery.protected_resource_metadata_urls(server_url: "https://api.example.com/mcp")
|
|
|
|
assert_equal(
|
|
[
|
|
"https://api.example.com/.well-known/oauth-protected-resource/mcp",
|
|
"https://api.example.com/.well-known/oauth-protected-resource",
|
|
],
|
|
urls,
|
|
)
|
|
end
|
|
|
|
def test_authorization_server_metadata_urls_for_root_issuer
|
|
urls = Discovery.authorization_server_metadata_urls("https://auth.example.com")
|
|
|
|
assert_equal(
|
|
[
|
|
"https://auth.example.com/.well-known/oauth-authorization-server",
|
|
"https://auth.example.com/.well-known/openid-configuration",
|
|
],
|
|
urls,
|
|
)
|
|
end
|
|
|
|
def test_authorization_server_metadata_urls_for_path_issuer
|
|
urls = Discovery.authorization_server_metadata_urls("https://auth.example.com/tenant1")
|
|
|
|
assert_includes(urls, "https://auth.example.com/.well-known/oauth-authorization-server/tenant1")
|
|
assert_includes(urls, "https://auth.example.com/.well-known/openid-configuration/tenant1")
|
|
assert_includes(urls, "https://auth.example.com/tenant1/.well-known/openid-configuration")
|
|
end
|
|
|
|
# Per SEP-2351, MCP explicitly uses the RFC 8414 default `oauth-authorization-server` well-known URI suffix
|
|
# and defines no application-specific suffix. The first probed candidate for a root issuer must be exactly
|
|
# that default suffix.
|
|
def test_authorization_server_metadata_urls_probe_rfc8414_default_suffix_first
|
|
urls = Discovery.authorization_server_metadata_urls("https://auth.example.com")
|
|
|
|
assert_equal("https://auth.example.com/.well-known/oauth-authorization-server", urls.first)
|
|
end
|
|
|
|
def test_authorization_server_metadata_urls_use_only_registered_well_known_suffixes
|
|
root_urls = Discovery.authorization_server_metadata_urls("https://auth.example.com")
|
|
path_urls = Discovery.authorization_server_metadata_urls("https://auth.example.com/tenant1")
|
|
|
|
(root_urls + path_urls).each do |url|
|
|
suffix = url[%r{/\.well-known/([^/]+)}, 1]
|
|
|
|
assert_includes(
|
|
["oauth-authorization-server", "openid-configuration"], suffix, "unexpected well-known suffix in #{url}"
|
|
)
|
|
end
|
|
end
|
|
|
|
def test_authorization_server_metadata_urls_put_path_inserted_oauth_candidate_first_for_path_issuer
|
|
urls = Discovery.authorization_server_metadata_urls("https://auth.example.com/tenant1")
|
|
|
|
assert_equal("https://auth.example.com/.well-known/oauth-authorization-server/tenant1", urls.first)
|
|
end
|
|
|
|
def test_authorization_server_metadata_urls_treat_trailing_slash_issuer_as_root
|
|
urls = Discovery.authorization_server_metadata_urls("https://auth.example.com/")
|
|
|
|
assert_equal(
|
|
["https://auth.example.com/.well-known/oauth-authorization-server", "https://auth.example.com/.well-known/openid-configuration"],
|
|
urls,
|
|
)
|
|
end
|
|
|
|
def test_infer_application_type_returns_native_for_loopback_http_redirect_uris
|
|
uris = ["http://localhost:0/callback", "http://127.0.0.1:8080/cb", "http://[::1]/cb"]
|
|
|
|
assert_equal("native", Discovery.infer_application_type(uris))
|
|
end
|
|
|
|
def test_infer_application_type_returns_native_for_custom_scheme_redirect_uris
|
|
assert_equal("native", Discovery.infer_application_type(["com.example.app:/oauth/callback"]))
|
|
end
|
|
|
|
def test_infer_application_type_returns_web_for_https_redirect_uris
|
|
assert_equal("web", Discovery.infer_application_type(["https://app.example.com/callback"]))
|
|
end
|
|
|
|
def test_infer_application_type_returns_web_when_any_redirect_uri_is_not_native
|
|
uris = ["http://localhost:0/callback", "https://app.example.com/callback"]
|
|
|
|
assert_equal("web", Discovery.infer_application_type(uris))
|
|
end
|
|
|
|
def test_infer_application_type_returns_web_for_localhost_lookalike_host
|
|
assert_equal("web", Discovery.infer_application_type(["http://localhost.example.com/cb"]))
|
|
end
|
|
|
|
def test_infer_application_type_returns_web_for_nil_or_empty_redirect_uris
|
|
assert_equal("web", Discovery.infer_application_type(nil))
|
|
assert_equal("web", Discovery.infer_application_type([]))
|
|
end
|
|
|
|
def test_infer_application_type_returns_web_for_unparseable_redirect_uri
|
|
assert_equal("web", Discovery.infer_application_type(["http://[invalid"]))
|
|
end
|
|
|
|
def test_canonicalize_url_normalizes_scheme_host_port_and_path
|
|
assert_equal(
|
|
"https://srv.example.com/mcp",
|
|
Discovery.canonicalize_url("HTTPS://Srv.Example.COM:443/mcp#frag"),
|
|
)
|
|
assert_equal(
|
|
"http://srv.example.com",
|
|
Discovery.canonicalize_url("http://Srv.Example.COM:80/"),
|
|
)
|
|
assert_equal(
|
|
"https://srv.example.com:8443/mcp",
|
|
Discovery.canonicalize_url("https://srv.example.com:8443/mcp"),
|
|
)
|
|
end
|
|
|
|
def test_canonicalize_url_resolves_dot_segments
|
|
# Critical for audience binding: an attacker-supplied URL like
|
|
# `/api/../mcp` must collapse to `/mcp` so it cannot pass a PRM check
|
|
# whose `resource` is the more privileged `/api` path.
|
|
assert_equal(
|
|
"https://srv.example.com/mcp",
|
|
Discovery.canonicalize_url("https://srv.example.com/api/../mcp"),
|
|
)
|
|
assert_equal(
|
|
"https://srv.example.com/api/mcp",
|
|
Discovery.canonicalize_url("https://srv.example.com/api/./mcp"),
|
|
)
|
|
assert_equal(
|
|
"https://srv.example.com/bar",
|
|
Discovery.canonicalize_url("https://srv.example.com/foo/../../bar"),
|
|
)
|
|
assert_equal(
|
|
"https://srv.example.com",
|
|
Discovery.canonicalize_url("https://srv.example.com/api/.."),
|
|
)
|
|
end
|
|
|
|
def test_canonicalize_url_resolves_percent_encoded_dot_segments
|
|
# `%2e` and `%2E` are percent-encoded forms of `.` (RFC 3986 Section 6.2.2.2)
|
|
# and must be decoded *before* dot-segment resolution. Otherwise,
|
|
# an attacker-supplied URL like `/api/%2e%2e/mcp` would slip past
|
|
# `remove_dot_segments` and escape the PRM `resource=/api` audience
|
|
# binding.
|
|
assert_equal(
|
|
"https://srv.example.com/mcp",
|
|
Discovery.canonicalize_url("https://srv.example.com/api/%2e%2e/mcp"),
|
|
)
|
|
assert_equal(
|
|
"https://srv.example.com/mcp",
|
|
Discovery.canonicalize_url("https://srv.example.com/api/%2E%2E/mcp"),
|
|
)
|
|
assert_equal(
|
|
"https://srv.example.com/api/mcp",
|
|
Discovery.canonicalize_url("https://srv.example.com/api/%2e/mcp"),
|
|
)
|
|
end
|
|
|
|
def test_canonicalize_url_drops_userinfo
|
|
# The canonicalized URL is sent on the wire as the RFC 8707 `resource`
|
|
# claim and is surfaced in error messages, so credentials in
|
|
# the authority must never round-trip: dropping `user:pass@` keeps them
|
|
# from leaking to the authorization server or to log destinations.
|
|
assert_equal(
|
|
"https://srv.example.com/mcp",
|
|
Discovery.canonicalize_url("https://user:pass@Srv.Example.COM:443/api/../mcp"),
|
|
)
|
|
end
|
|
|
|
def test_redact_url_drops_credentials_query_and_fragment_but_keeps_the_spelling
|
|
# Reported next to a failed request, the URL must still match the server's access log, so the host
|
|
# and path are left alone: no lowercasing of the host, no dot-segment resolution, no decoding.
|
|
assert_equal(
|
|
"https://Srv.Example.COM:8443/tenant/%2e%2e/prm%2Ejson",
|
|
Discovery.redact_url("https://user:pass@Srv.Example.COM:8443/tenant/%2e%2e/prm%2Ejson?token=abc#frag"),
|
|
)
|
|
end
|
|
|
|
def test_redact_url_does_not_echo_a_url_it_cannot_parse
|
|
assert_equal("[unparseable URL]", Discovery.redact_url("https://user:pass@srv.example.com/pr m.json?token=abc"))
|
|
end
|
|
|
|
def test_canonicalize_url_normalizes_query_to_match_faraday
|
|
# Faraday rewrites `env.url` before sending a request: it sorts
|
|
# parameters by name, uppercases percent-encoded hex, and drops
|
|
# a bare `?` with no parameters. The canonical form must apply
|
|
# the same transformation so the URL guard does not raise a false
|
|
# positive on every valid request with a query string.
|
|
assert_equal(
|
|
"https://srv.example.com/mcp?a=1&b=2",
|
|
Discovery.canonicalize_url("https://srv.example.com/mcp?b=2&a=1"),
|
|
)
|
|
assert_equal(
|
|
"https://srv.example.com/mcp?x=%2F",
|
|
Discovery.canonicalize_url("https://srv.example.com/mcp?x=%2f"),
|
|
)
|
|
assert_equal(
|
|
"https://srv.example.com/mcp",
|
|
Discovery.canonicalize_url("https://srv.example.com/mcp?"),
|
|
)
|
|
end
|
|
|
|
def test_canonicalize_url_collapses_same_name_query_parameters
|
|
# Faraday's internal query encoder uses a Hash, so repeated keys
|
|
# collapse to the last value. The canonical form must apply
|
|
# the same collapse, otherwise the URL guard false-positives on URLs
|
|
# like `?a=1&a=2` (Faraday rewrites to `?a=2`).
|
|
assert_equal(
|
|
"https://srv.example.com/mcp?a=2",
|
|
Discovery.canonicalize_url("https://srv.example.com/mcp?a=1&a=2"),
|
|
)
|
|
assert_equal(
|
|
"https://srv.example.com/mcp?a=3&b=2",
|
|
Discovery.canonicalize_url("https://srv.example.com/mcp?a=1&b=2&a=3"),
|
|
)
|
|
assert_equal(
|
|
"https://srv.example.com/mcp?x=%2F",
|
|
Discovery.canonicalize_url("https://srv.example.com/mcp?x=%2f&x=%2F"),
|
|
)
|
|
end
|
|
|
|
def test_canonicalize_url_preserves_valueless_vs_empty_value_distinction
|
|
# Faraday preserves the difference between `?key` (no `=`) and
|
|
# `?key=` (empty value) exactly as the caller passed it. A naive
|
|
# `URI.decode_www_form` round-trip would collapse both to `?key=`
|
|
# and false-positive the URL guard / RFC 8707 `resource` check for
|
|
# any URL that uses the value-less form.
|
|
assert_equal(
|
|
"https://srv.example.com/mcp?tenant",
|
|
Discovery.canonicalize_url("https://srv.example.com/mcp?tenant"),
|
|
)
|
|
assert_equal(
|
|
"https://srv.example.com/mcp?tenant=",
|
|
Discovery.canonicalize_url("https://srv.example.com/mcp?tenant="),
|
|
)
|
|
assert_equal(
|
|
"https://srv.example.com/mcp?a=1&b",
|
|
Discovery.canonicalize_url("https://srv.example.com/mcp?a=1&b"),
|
|
)
|
|
# Collapse with last-write-wins also keeps the `=` form of
|
|
# the surviving segment: `flag=1&flag` -> last is `flag` (no `=`), so
|
|
# the canonical form is `?flag`, matching Faraday.
|
|
assert_equal(
|
|
"https://srv.example.com/mcp?flag",
|
|
Discovery.canonicalize_url("https://srv.example.com/mcp?flag=1&flag"),
|
|
)
|
|
end
|
|
|
|
def test_canonicalize_url_preserves_array_form_repeated_keys
|
|
# Keys ending in `[]` are Faraday's array notation: the encoder
|
|
# preserves every occurrence in input order instead of collapsing
|
|
# them. The canonical form must do the same; otherwise a hijacked
|
|
# middleware that drops one entry from `?tenant[]=victim&tenant[]=...`
|
|
# would slip past the URL guard, and a legitimate
|
|
# `?roles[]=a&roles[]=b` URL would false-positive on the first
|
|
# request.
|
|
assert_equal(
|
|
"https://srv.example.com/mcp?a%5B%5D=1&a%5B%5D=2",
|
|
Discovery.canonicalize_url("https://srv.example.com/mcp?a[]=1&a[]=2"),
|
|
)
|
|
assert_equal(
|
|
"https://srv.example.com/mcp?tenant%5B%5D=evil&tenant%5B%5D=victim",
|
|
Discovery.canonicalize_url("https://srv.example.com/mcp?tenant[]=evil&tenant[]=victim"),
|
|
)
|
|
assert_equal(
|
|
"https://srv.example.com/mcp?a%5B%5D=1&a%5B%5D=2&b=3",
|
|
Discovery.canonicalize_url("https://srv.example.com/mcp?a[]=1&a[]=2&b=3"),
|
|
)
|
|
# Nested keys like `a[b]` are not array notation. They collapse with
|
|
# last-write-wins, matching Faraday's `NestedParamsEncoder`.
|
|
assert_equal(
|
|
"https://srv.example.com/mcp?a%5Bb%5D=2",
|
|
Discovery.canonicalize_url("https://srv.example.com/mcp?a[b]=1&a[b]=2"),
|
|
)
|
|
end
|
|
|
|
def test_canonicalize_url_drops_empty_name_and_blank_query_segments
|
|
# Faraday's encoder treats pairs with an empty name and blank `&&`
|
|
# separators as no parameter at all. The canonical form must do
|
|
# the same; otherwise URLs like `?=v` or `?&&a=1&&` produce a snapshot
|
|
# that disagrees with the Faraday-rewritten effective URL and
|
|
# the request-time guard false-positives.
|
|
assert_equal(
|
|
"https://srv.example.com/mcp",
|
|
Discovery.canonicalize_url("https://srv.example.com/mcp?=v"),
|
|
)
|
|
assert_equal(
|
|
"https://srv.example.com/mcp?a=1",
|
|
Discovery.canonicalize_url("https://srv.example.com/mcp?&&a=1&&"),
|
|
)
|
|
assert_equal(
|
|
"https://srv.example.com/mcp?a=2",
|
|
Discovery.canonicalize_url("https://srv.example.com/mcp?a=1&&a=2"),
|
|
)
|
|
assert_equal(
|
|
"https://srv.example.com/mcp?a=1",
|
|
Discovery.canonicalize_url("https://srv.example.com/mcp?=&=&a=1"),
|
|
)
|
|
assert_equal(
|
|
"https://srv.example.com/mcp",
|
|
Discovery.canonicalize_url("https://srv.example.com/mcp?&"),
|
|
)
|
|
end
|
|
|
|
def test_resource_covers_blocks_dot_segment_bypass_after_canonicalization
|
|
server = Discovery.canonicalize_url("https://srv.example.com/api/../mcp")
|
|
prm = Discovery.canonicalize_url("https://srv.example.com/api")
|
|
|
|
refute(
|
|
Discovery.resource_covers?(prm: prm, server: server),
|
|
"PRM `resource=/api` must not cover `/api/../mcp` after canonicalization",
|
|
)
|
|
end
|
|
|
|
def test_secure_url_accepts_https
|
|
assert(Discovery.secure_url?("https://srv.example.com/mcp"))
|
|
assert(Discovery.secure_url?("HTTPS://Srv.Example.COM/mcp"))
|
|
end
|
|
|
|
def test_secure_url_accepts_loopback_http
|
|
assert(Discovery.secure_url?("http://localhost/x"))
|
|
assert(Discovery.secure_url?("http://localhost:8080/x"))
|
|
assert(Discovery.secure_url?("http://127.0.0.1/x"))
|
|
assert(Discovery.secure_url?("http://127.42.0.5/x"))
|
|
assert(Discovery.secure_url?("http://127.255.255.255/x"))
|
|
assert(Discovery.secure_url?("http://[::1]/x"))
|
|
end
|
|
|
|
def test_secure_url_rejects_non_loopback_http
|
|
refute(Discovery.secure_url?("http://srv.example.com/mcp"))
|
|
refute(Discovery.secure_url?("http://192.168.1.1/x"))
|
|
refute(Discovery.secure_url?("http://10.0.0.1/x"))
|
|
end
|
|
|
|
def test_secure_url_rejects_hostname_tricks_that_resemble_loopback
|
|
# Naive `start_with?("127.")` would let these slip through; IPAddr-based
|
|
# checks correctly classify them as non-loopback hostnames.
|
|
refute(Discovery.secure_url?("http://127.attacker.com/x"))
|
|
refute(Discovery.secure_url?("http://127.0.0.1.evil.com/x"))
|
|
refute(Discovery.secure_url?("http://127./x"))
|
|
# `localhost` MUST be matched exactly - `foo.localhost` is just a regular
|
|
# hostname that happens to share the suffix.
|
|
refute(Discovery.secure_url?("http://foo.localhost/x"))
|
|
end
|
|
|
|
def test_secure_url_rejects_non_http_schemes
|
|
refute(Discovery.secure_url?("ftp://srv.example.com/mcp"))
|
|
refute(Discovery.secure_url?("file:///etc/passwd"))
|
|
refute(Discovery.secure_url?("javascript:alert(1)"))
|
|
refute(Discovery.secure_url?(""))
|
|
refute(Discovery.secure_url?(nil))
|
|
end
|
|
|
|
def test_secure_url_rejects_malformed_or_hostless_urls
|
|
refute(Discovery.secure_url?("https:/missing-host"))
|
|
refute(Discovery.secure_url?("https:///missing-host"))
|
|
refute(Discovery.secure_url?("https://"))
|
|
refute(Discovery.secure_url?("not a url"))
|
|
end
|
|
|
|
def test_same_origin_accepts_matching_scheme_host_and_port
|
|
assert(Discovery.same_origin?("https://srv.example.com/.well-known/x", "https://srv.example.com/mcp"))
|
|
assert(Discovery.same_origin?("https://srv.example.com:8443/a", "https://srv.example.com:8443/b"))
|
|
assert(Discovery.same_origin?("http://localhost:9292/a", "http://localhost:9292/mcp"))
|
|
assert(Discovery.same_origin?("https://srv.example.com/a?q=1#f", "https://srv.example.com/mcp"))
|
|
end
|
|
|
|
def test_same_origin_ignores_case_in_scheme_and_host
|
|
assert(Discovery.same_origin?("HTTPS://SRV.Example.COM/a", "https://srv.example.com/mcp"))
|
|
end
|
|
|
|
def test_same_origin_treats_an_explicit_default_port_as_the_implicit_one
|
|
assert(Discovery.same_origin?("https://srv.example.com:443/a", "https://srv.example.com/mcp"))
|
|
assert(Discovery.same_origin?("http://srv.example.com:80/a", "http://srv.example.com/mcp"))
|
|
end
|
|
|
|
def test_same_origin_rejects_differing_components
|
|
refute(Discovery.same_origin?("https://attacker.example.com/a", "https://srv.example.com/mcp"))
|
|
refute(Discovery.same_origin?("https://169.254.169.254/latest/", "https://srv.example.com/mcp"))
|
|
refute(Discovery.same_origin?("https://srv.example.com:8443/a", "https://srv.example.com/mcp"))
|
|
refute(Discovery.same_origin?("http://srv.example.com/a", "https://srv.example.com/mcp"))
|
|
# A subdomain is a different origin, so a hijacked challenge cannot walk
|
|
# sideways into a sibling host that shares the registrable domain.
|
|
refute(Discovery.same_origin?("https://evil.srv.example.com/a", "https://srv.example.com/mcp"))
|
|
end
|
|
|
|
def test_same_origin_rejects_malformed_or_hostless_urls
|
|
refute(Discovery.same_origin?("not a url", "https://srv.example.com/mcp"))
|
|
refute(Discovery.same_origin?("https://srv.example.com/a", "not a url"))
|
|
refute(Discovery.same_origin?("https://", "https://srv.example.com/mcp"))
|
|
refute(Discovery.same_origin?(nil, "https://srv.example.com/mcp"))
|
|
refute(Discovery.same_origin?("/relative/path", "https://srv.example.com/mcp"))
|
|
end
|
|
|
|
def test_private_network_host_recognizes_reserved_ipv4_ranges
|
|
assert(Discovery.private_network_host?("0.0.0.0"))
|
|
assert(Discovery.private_network_host?("10.0.0.5"))
|
|
assert(Discovery.private_network_host?("100.64.0.1"))
|
|
assert(Discovery.private_network_host?("127.0.0.1"))
|
|
# The cloud metadata address this check exists for.
|
|
assert(Discovery.private_network_host?("169.254.169.254"))
|
|
assert(Discovery.private_network_host?("172.16.0.1"))
|
|
assert(Discovery.private_network_host?("172.31.255.255"))
|
|
assert(Discovery.private_network_host?("192.168.1.1"))
|
|
end
|
|
|
|
def test_private_network_host_recognizes_reserved_ipv6_ranges
|
|
assert(Discovery.private_network_host?("::"))
|
|
assert(Discovery.private_network_host?("::1"))
|
|
assert(Discovery.private_network_host?("[::1]"))
|
|
assert(Discovery.private_network_host?("fd00::1"))
|
|
assert(Discovery.private_network_host?("fe80::1"))
|
|
assert(Discovery.private_network_host?("[fe80::1]"))
|
|
end
|
|
|
|
def test_private_network_host_unwraps_ipv4_mapped_ipv6
|
|
# `::ffff:169.254.169.254` reaches the same interface as the IPv4 spelling,
|
|
# so the mapped form must not be a way around the ranges above.
|
|
assert(Discovery.private_network_host?("::ffff:169.254.169.254"))
|
|
assert(Discovery.private_network_host?("[::ffff:10.0.0.5]"))
|
|
assert(Discovery.private_network_host?("::ffff:127.0.0.1"))
|
|
refute(Discovery.private_network_host?("::ffff:93.184.216.34"))
|
|
end
|
|
|
|
def test_private_network_host_refuses_non_canonical_ipv4_spellings
|
|
# `inet_aton` accepts forms `IPAddr` rejects, and the resolver reaches the same
|
|
# interfaces through them: `2130706433` and `127.1` are 127.0.0.1, and
|
|
# `0xa9fea9fe` is the cloud metadata address. Checking only the dotted-decimal
|
|
# spelling would refuse `169.254.169.254` and admit every alias of it.
|
|
assert(Discovery.private_network_host?("2130706433"))
|
|
assert(Discovery.private_network_host?("0x7f000001"))
|
|
assert(Discovery.private_network_host?("0xa9fea9fe"))
|
|
assert(Discovery.private_network_host?("127.1"))
|
|
assert(Discovery.private_network_host?("127.0.1"))
|
|
assert(Discovery.private_network_host?("0x7f.0.0.1"))
|
|
assert(Discovery.private_network_host?("0251.0376.0251.0376"))
|
|
end
|
|
|
|
def test_private_network_host_refuses_numeric_spellings_whose_value_is_resolver_dependent
|
|
# `010.0.0.1` is 8.0.0.1 read as octal and 10.0.0.1 read as decimal, and platforms
|
|
# disagree on which it is. Both readings are refused rather than one being chosen,
|
|
# since the reading not taken would otherwise be a way through.
|
|
assert(Discovery.private_network_host?("010.0.0.1"))
|
|
assert(Discovery.private_network_host?("0177.0.0.1"))
|
|
|
|
# The same applies to numeric spellings that land outside the blocked ranges, or
|
|
# nowhere at all: nothing is addressed this way on purpose.
|
|
assert(Discovery.private_network_host?("0x8080808"))
|
|
assert(Discovery.private_network_host?("256.1.1.1"))
|
|
end
|
|
|
|
def test_private_network_host_recognizes_localhost_by_name_only
|
|
assert(Discovery.private_network_host?("localhost"))
|
|
assert(Discovery.private_network_host?("LOCALHOST"))
|
|
refute(Discovery.private_network_host?("foo.localhost"))
|
|
refute(Discovery.private_network_host?("localhost.attacker.com"))
|
|
end
|
|
|
|
def test_private_network_host_rejects_public_addresses_and_names
|
|
refute(Discovery.private_network_host?("93.184.216.34"))
|
|
refute(Discovery.private_network_host?("8.8.8.8"))
|
|
refute(Discovery.private_network_host?("172.32.0.1"))
|
|
refute(Discovery.private_network_host?("2606:4700::1111"))
|
|
refute(Discovery.private_network_host?("srv.example.com"))
|
|
refute(Discovery.private_network_host?(nil))
|
|
refute(Discovery.private_network_host?(""))
|
|
end
|
|
|
|
def test_private_network_host_does_not_resolve_names
|
|
# Documents the deliberate limit of the predicate: an internal host that is
|
|
# named rather than addressed reads as public here. `same_origin?` is what
|
|
# keeps the `resource_metadata` URL from reaching one.
|
|
refute(Discovery.private_network_host?("vault.corp.internal"))
|
|
end
|
|
|
|
def test_client_id_metadata_document_url_accepts_https_with_path
|
|
assert(Discovery.client_id_metadata_document_url?("https://app.example.com/client-metadata.json"))
|
|
assert(Discovery.client_id_metadata_document_url?("https://app.example.com/path/to/cm"))
|
|
assert(Discovery.client_id_metadata_document_url?("https://app.example.com:8443/cm"))
|
|
end
|
|
|
|
def test_client_id_metadata_document_url_accepts_boundary_forms
|
|
# An uppercase scheme is `https` after case folding (URIs are scheme-insensitive, RFC 3986 Section 3.1).
|
|
assert(Discovery.client_id_metadata_document_url?("HTTPS://app.example.com/cm"))
|
|
|
|
# A trailing slash and consecutive slashes are non-root, non-dot paths.
|
|
# RFC 3986 `remove_dot_segments` does not collapse them, so they remain
|
|
# stable `client_id` strings rather than aliasing another URL.
|
|
assert(Discovery.client_id_metadata_document_url?("https://app.example.com/cm/"))
|
|
assert(Discovery.client_id_metadata_document_url?("https://app.example.com//cm"))
|
|
|
|
# The default https port is equivalent to omitting it; either form is a valid origin.
|
|
assert(Discovery.client_id_metadata_document_url?("https://app.example.com:443/cm"))
|
|
|
|
# IPv6 literal hosts and Punycode (IDN) hosts are ordinary hosts.
|
|
assert(Discovery.client_id_metadata_document_url?("https://[2001:db8::1]/cm"))
|
|
assert(Discovery.client_id_metadata_document_url?("https://[::1]/cm"))
|
|
assert(Discovery.client_id_metadata_document_url?("https://xn--e1afmkfd.example.com/cm"))
|
|
end
|
|
|
|
def test_client_id_metadata_document_url_rejects_non_https_schemes
|
|
refute(Discovery.client_id_metadata_document_url?("http://localhost/cm"))
|
|
refute(Discovery.client_id_metadata_document_url?("http://app.example.com/cm"))
|
|
refute(Discovery.client_id_metadata_document_url?("ftp://app.example.com/cm"))
|
|
refute(Discovery.client_id_metadata_document_url?("file:///cm"))
|
|
end
|
|
|
|
def test_client_id_metadata_document_url_rejects_root_and_empty_path
|
|
refute(Discovery.client_id_metadata_document_url?("https://app.example.com"))
|
|
refute(Discovery.client_id_metadata_document_url?("https://app.example.com/"))
|
|
end
|
|
|
|
def test_client_id_metadata_document_url_rejects_fragment_query_userinfo
|
|
refute(Discovery.client_id_metadata_document_url?("https://app.example.com/cm#frag"))
|
|
refute(Discovery.client_id_metadata_document_url?("https://app.example.com/cm?x=1"))
|
|
refute(Discovery.client_id_metadata_document_url?("https://user:pass@app.example.com/cm"))
|
|
refute(Discovery.client_id_metadata_document_url?("https://user@app.example.com/cm"))
|
|
end
|
|
|
|
def test_client_id_metadata_document_url_rejects_dot_segments
|
|
refute(Discovery.client_id_metadata_document_url?("https://app.example.com/./cm"))
|
|
refute(Discovery.client_id_metadata_document_url?("https://app.example.com/a/../cm"))
|
|
refute(Discovery.client_id_metadata_document_url?("https://app.example.com/cm/."))
|
|
refute(Discovery.client_id_metadata_document_url?("https://app.example.com/cm/.."))
|
|
# Percent-encoded `.` would otherwise let `%2E` and `.` produce
|
|
# different `client_id` values for the same document.
|
|
refute(Discovery.client_id_metadata_document_url?("https://app.example.com/%2E/cm"))
|
|
refute(Discovery.client_id_metadata_document_url?("https://app.example.com/%2e/cm"))
|
|
end
|
|
|
|
def test_client_id_metadata_document_url_rejects_nil_or_empty_or_malformed
|
|
refute(Discovery.client_id_metadata_document_url?(nil))
|
|
refute(Discovery.client_id_metadata_document_url?(""))
|
|
refute(Discovery.client_id_metadata_document_url?("not a url"))
|
|
refute(Discovery.client_id_metadata_document_url?("https://"))
|
|
end
|
|
|
|
def test_resource_covers_blocks_percent_encoded_dot_segment_bypass
|
|
server = Discovery.canonicalize_url("https://srv.example.com/api/%2e%2e/mcp")
|
|
prm = Discovery.canonicalize_url("https://srv.example.com/api")
|
|
|
|
refute(
|
|
Discovery.resource_covers?(prm: prm, server: server),
|
|
"PRM `resource=/api` must not cover `/api/%2e%2e/mcp` after canonicalization",
|
|
)
|
|
end
|
|
|
|
def test_resource_covers_blocks_cross_tenant_query_bypass
|
|
# An attacker-controlled PRM that advertises a different tenant's
|
|
# resource URI MUST NOT be treated as covering the server URL. If it
|
|
# were, the client would mint a token for `tenant=evil` while
|
|
# actually talking to `tenant=victim`, defeating audience binding.
|
|
server = Discovery.canonicalize_url("https://srv.example.com/mcp?tenant=victim")
|
|
prm_evil = Discovery.canonicalize_url("https://srv.example.com/mcp?tenant=evil")
|
|
|
|
refute(
|
|
Discovery.resource_covers?(prm: prm_evil, server: server),
|
|
"PRM `resource=?tenant=evil` must not cover server `?tenant=victim`",
|
|
)
|
|
end
|
|
|
|
def test_resource_covers_allows_matching_query
|
|
server = Discovery.canonicalize_url("https://srv.example.com/mcp?tenant=victim")
|
|
prm = Discovery.canonicalize_url("https://srv.example.com/mcp?tenant=victim")
|
|
|
|
assert(Discovery.resource_covers?(prm: prm, server: server))
|
|
end
|
|
|
|
def test_resource_covers_allows_prm_without_query_to_cover_server_with_query
|
|
# A query-less PRM acts as a generic identifier over the origin +
|
|
# path prefix and is allowed to cover server URLs that scope by query.
|
|
server = Discovery.canonicalize_url("https://srv.example.com/mcp?tenant=1")
|
|
prm = Discovery.canonicalize_url("https://srv.example.com/mcp")
|
|
|
|
assert(Discovery.resource_covers?(prm: prm, server: server))
|
|
end
|
|
|
|
def test_resource_covers_blocks_prm_with_query_when_server_has_none
|
|
# PRM that names a specific tenant cannot cover an unscoped server
|
|
# URL; otherwise a hijacked PRM could re-bind the token to a tenant
|
|
# the user did not select.
|
|
server = Discovery.canonicalize_url("https://srv.example.com/mcp")
|
|
prm = Discovery.canonicalize_url("https://srv.example.com/mcp?tenant=evil")
|
|
|
|
refute(Discovery.resource_covers?(prm: prm, server: server))
|
|
end
|
|
|
|
def test_resource_covers_does_not_treat_empty_query_as_wildcard
|
|
# `https://srv.example.com/mcp?` parses with `URI#query == ""`, which
|
|
# is distinct from "no query at all" (`URI#query == nil`). A naive
|
|
# `prm_query.nil? || prm_query.empty?` would let an attacker-supplied
|
|
# PRM with a trailing `?` slip past the cross-tenant audience check.
|
|
server = Discovery.canonicalize_url("https://srv.example.com/mcp?tenant=victim")
|
|
prm_empty_query = "https://srv.example.com/mcp?"
|
|
|
|
refute(
|
|
Discovery.resource_covers?(prm: prm_empty_query, server: server),
|
|
"PRM with a literal empty query (`?`) must not act as a wildcard cover",
|
|
)
|
|
end
|
|
|
|
def test_resource_covers_returns_true_for_same_origin_prefix
|
|
assert(Discovery.resource_covers?(
|
|
prm: "https://srv.example.com",
|
|
server: "https://srv.example.com/mcp",
|
|
))
|
|
assert(Discovery.resource_covers?(
|
|
prm: "https://srv.example.com/mcp",
|
|
server: "https://srv.example.com/mcp",
|
|
))
|
|
assert(Discovery.resource_covers?(
|
|
prm: "https://srv.example.com/api",
|
|
server: "https://srv.example.com/api/v2/mcp",
|
|
))
|
|
end
|
|
|
|
def test_resource_covers_returns_false_for_different_origin_or_path_mismatch
|
|
refute(Discovery.resource_covers?(
|
|
prm: "https://evil.example.com/mcp",
|
|
server: "https://srv.example.com/mcp",
|
|
))
|
|
refute(Discovery.resource_covers?(
|
|
prm: "https://srv.example.com:8443/mcp",
|
|
server: "https://srv.example.com/mcp",
|
|
))
|
|
# Path "api" must not be considered a prefix of "/apiv2".
|
|
refute(Discovery.resource_covers?(
|
|
prm: "https://srv.example.com/api",
|
|
server: "https://srv.example.com/apiv2",
|
|
))
|
|
end
|
|
end
|
|
end
|
|
end
|
|
end
|