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.
This commit is contained in:
Koichi ITO 2026-07-29 18:48:50 +09:00
commit aaf0c71995
3 changed files with 601 additions and 27 deletions

View file

@ -437,6 +437,121 @@ module MCP
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"))

View file

@ -68,6 +68,23 @@ module MCP
WebMock.reset!
end
# A plain authorization-code provider for the destination-check tests.
# They all expect to fail before any redirect happens, so the handlers stay inert;
# a test that reached them would be asserting the wrong thing.
def ssrf_test_provider
Provider.new(
client_metadata: {
redirect_uris: ["http://localhost:0/callback"],
grant_types: ["authorization_code"],
response_types: ["code"],
token_endpoint_auth_method: "none",
},
redirect_uri: "http://localhost:0/callback",
redirect_handler: ->(_url) {},
callback_handler: -> { ["code", "state"] },
)
end
def client_credentials_provider(token_endpoint_auth_method: "client_secret_basic")
ClientCredentialsProvider.new(
client_id: "cc-client",
@ -1744,10 +1761,212 @@ module MCP
assert_match(/resource_metadata|HTTPS|Communication Security/i, error.message)
end
def test_run_does_not_fetch_a_resource_metadata_url_off_the_server_origin
# A server that answers 401 chooses the `resource_metadata` URL, and discovery puts it first
# in the candidate list. Left unchecked, a single response header aims the client's first OAuth request
# at any host it can route to, including the cloud metadata address. The assertion that matters is not
# the raise but `assert_not_requested`: nothing downstream can un-send a request.
metadata_url = "https://169.254.169.254/latest/meta-data/iam/"
stub_request(:get, metadata_url).to_return(status: 200, body: "{}")
error = assert_raises(Flow::AuthorizationError) do
Flow.new(provider: ssrf_test_provider).run!(
server_url: @server_url,
resource_metadata_url: metadata_url,
)
end
assert_not_requested(:get, metadata_url)
assert_match(/resource_metadata/i, error.message)
assert_match(/origin/i, error.message)
end
def test_run_does_not_fetch_an_https_resource_metadata_url_on_a_foreign_public_origin
# The scheme check alone passes this one: it is HTTPS, just not the server's.
metadata_url = "https://attacker.example.com/.well-known/oauth-protected-resource"
stub_request(:get, metadata_url).to_return(status: 200, body: "{}")
assert_raises(Flow::AuthorizationError) do
Flow.new(provider: ssrf_test_provider).run!(
server_url: @server_url,
resource_metadata_url: metadata_url,
)
end
assert_not_requested(:get, metadata_url)
end
def test_refresh_does_not_fetch_a_resource_metadata_url_off_the_server_origin
# `refresh!` reads the same header on a 401 and must gate it identically;
# otherwise the token-refresh path stays reachable after `run!` is closed.
metadata_url = "https://169.254.169.254/latest/meta-data/iam/"
stub_request(:get, metadata_url).to_return(status: 200, body: "{}")
provider = ssrf_test_provider
provider.save_client_information("client_id" => "test-client")
provider.save_tokens("access_token" => "stale-at", "refresh_token" => "saved-rt")
assert_raises(Flow::AuthorizationError) do
Flow.new(provider: provider).refresh!(
server_url: @server_url,
resource_metadata_url: metadata_url,
)
end
assert_not_requested(:get, metadata_url)
end
def test_run_does_not_contact_an_authorization_server_on_a_private_address
# One hop deeper than the `resource_metadata` URL: the server serves its own PRM from its own origin,
# then names a private address as its authorization server. Same-origin cannot see this one,
# so the range check has to.
stub_request(:get, @prm_url).to_return(
status: 200,
headers: { "Content-Type" => "application/json" },
body: JSON.generate(
resource: @server_url,
authorization_servers: ["https://169.254.169.254"],
),
)
stub_request(:get, %r{\Ahttps://169\.254\.169\.254/}).to_return(status: 200, body: "{}")
error = assert_raises(Flow::AuthorizationError) do
Flow.new(provider: ssrf_test_provider).run!(server_url: @server_url, resource_metadata_url: @prm_url)
end
assert_not_requested(:get, %r{\Ahttps://169\.254\.169\.254/})
assert_match(/private network/i, error.message)
end
def test_run_does_not_contact_an_authorization_server_spelled_as_a_packed_integer
# `0xa9fea9fe` is 169.254.169.254 written for `inet_aton`. The connection resolves it to
# the same address, so the previous test's protection has to survive the rewrite.
stub_request(:get, @prm_url).to_return(
status: 200,
headers: { "Content-Type" => "application/json" },
body: JSON.generate(
resource: @server_url,
authorization_servers: ["https://0xa9fea9fe"],
),
)
stub_request(:get, %r{\Ahttps://0xa9fea9fe/}).to_return(status: 200, body: "{}")
error = assert_raises(Flow::AuthorizationError) do
Flow.new(provider: ssrf_test_provider).run!(server_url: @server_url, resource_metadata_url: @prm_url)
end
assert_not_requested(:get, %r{\Ahttps://0xa9fea9fe/})
assert_match(/private network/i, error.message)
end
def test_run_does_not_post_to_a_token_endpoint_on_a_private_address
# The token endpoint receives the client credentials and the authorization code,
# so an AS document that relocates it inside the client's network is a credential disclosure,
# not just a probe.
stub_request(:get, @as_metadata_url).to_return(
status: 200,
headers: { "Content-Type" => "application/json" },
body: JSON.generate(
issuer: @auth_base,
authorization_endpoint: "#{@auth_base}/authorize",
token_endpoint: "https://10.0.0.5/token",
registration_endpoint: "#{@auth_base}/register",
code_challenge_methods_supported: ["S256"],
token_endpoint_auth_methods_supported: ["none"],
),
)
stub_request(:post, "https://10.0.0.5/token").to_return(status: 200, body: "{}")
error = assert_raises(Flow::AuthorizationError) do
Flow.new(provider: ssrf_test_provider).run!(server_url: @server_url, resource_metadata_url: @prm_url)
end
assert_not_requested(:post, "https://10.0.0.5/token")
assert_match(/private network/i, error.message)
end
def test_run_does_not_post_to_a_registration_endpoint_on_a_private_address
stub_request(:get, @as_metadata_url).to_return(
status: 200,
headers: { "Content-Type" => "application/json" },
body: JSON.generate(
issuer: @auth_base,
authorization_endpoint: "#{@auth_base}/authorize",
token_endpoint: "#{@auth_base}/token",
registration_endpoint: "https://192.168.1.1/register",
code_challenge_methods_supported: ["S256"],
token_endpoint_auth_methods_supported: ["none"],
),
)
stub_request(:post, "https://192.168.1.1/register").to_return(status: 201, body: "{}")
assert_raises(Flow::AuthorizationError) do
Flow.new(provider: ssrf_test_provider).run!(server_url: @server_url, resource_metadata_url: @prm_url)
end
assert_not_requested(:post, "https://192.168.1.1/register")
end
def test_run_allows_a_private_authorization_server_when_the_mcp_server_is_also_private
# The shape the conformance harness runs and the shape local development takes:
# the MCP server and the authorization server sit on two loopback ports.
# Once the caller has pointed the client at a private address, the authorization server
# living there too is not an escalation, and refusing it would break every local setup.
server_url = "http://localhost:9292/mcp"
prm_url = "http://localhost:9292/.well-known/oauth-protected-resource/mcp"
auth_base = "http://localhost:9393"
stub_request(:get, prm_url).to_return(
status: 200,
headers: { "Content-Type" => "application/json" },
body: JSON.generate(resource: server_url, authorization_servers: [auth_base]),
)
stub_request(:get, "#{auth_base}/.well-known/oauth-authorization-server").to_return(
status: 200,
headers: { "Content-Type" => "application/json" },
body: JSON.generate(
issuer: auth_base,
authorization_endpoint: "#{auth_base}/authorize",
token_endpoint: "#{auth_base}/token",
registration_endpoint: "#{auth_base}/register",
code_challenge_methods_supported: ["S256"],
token_endpoint_auth_methods_supported: ["none"],
),
)
stub_request(:post, "#{auth_base}/register").to_return(
status: 201,
headers: { "Content-Type" => "application/json" },
body: JSON.generate(client_id: "local-client"),
)
stub_request(:post, "#{auth_base}/token").to_return(
status: 200,
headers: { "Content-Type" => "application/json" },
body: JSON.generate(access_token: "local-token", token_type: "Bearer", expires_in: 3600),
)
state_holder = {}
provider = Provider.new(
client_metadata: {
redirect_uris: ["http://localhost:0/callback"],
grant_types: ["authorization_code"],
response_types: ["code"],
token_endpoint_auth_method: "none",
},
redirect_uri: "http://localhost:0/callback",
redirect_handler: ->(url) { state_holder[:state] = URI.decode_www_form(url.query).to_h.fetch("state") },
callback_handler: -> { ["test-auth-code", state_holder[:state]] },
)
result = Flow.new(provider: provider).run!(server_url: server_url, resource_metadata_url: prm_url)
assert_equal(:authorized, result)
assert_equal("local-token", provider.access_token)
end
def test_run_rejects_when_as_metadata_issuer_does_not_match
# RFC 8414 Section 3.3: the AS metadata's `issuer` value MUST equal
# the discovery URL. A metadata document advertising a different issuer
# than the one PRM pointed at is treated as a confused-deputy attempt.
# RFC 8414 Section 3.3: the AS metadata's `issuer` value MUST equal the discovery URL.
# A metadata document advertising a different issuer than the one PRM pointed at is treated
# as a confused-deputy attempt.
stub_request(:get, @as_metadata_url).to_return(
status: 200,
headers: { "Content-Type" => "application/json" },
@ -1781,9 +2000,8 @@ module MCP
end
def test_run_rejects_when_as_metadata_issuer_differs_by_trailing_slash
# RFC 8414 Section 3.3 requires the issuer to be identical, not "equivalent
# after normalization". `https://auth.example.com` and
# `https://auth.example.com/` are different strings and MUST NOT match.
# RFC 8414 Section 3.3 requires the issuer to be identical, not "equivalent after normalization".
# `https://auth.example.com` and `https://auth.example.com/` are different strings and MUST NOT match.
stub_request(:get, @as_metadata_url).to_return(
status: 200,
headers: { "Content-Type" => "application/json" },

View file

@ -101,6 +101,43 @@ module MCP
assert_equal("test-token-after-flow", provider.access_token)
end
def test_send_request_does_not_follow_a_resource_metadata_challenge_off_the_server_origin
# End to end over the transport, which is where the header is actually parsed:
# a server that answers 401 must not be able to name an unrelated host in
# `resource_metadata` and have the client fetch it. The request never leaving
# is the point; the 401 surfacing to the caller is secondary.
metadata_url = "https://169.254.169.254/latest/meta-data/iam/"
stub_request(:post, @mcp_url).to_return(
status: 401,
headers: {
"WWW-Authenticate" => %(Bearer error="invalid_token", resource_metadata="#{metadata_url}"),
},
body: "",
)
stub_request(:get, metadata_url).to_return(status: 200, body: "{}")
provider = Provider.new(
client_metadata: {
redirect_uris: ["http://localhost:0/callback"],
grant_types: ["authorization_code"],
response_types: ["code"],
token_endpoint_auth_method: "none",
},
redirect_uri: "http://localhost:0/callback",
redirect_handler: ->(_url) {},
callback_handler: -> { ["code", "state"] },
)
transport = HTTP.new(url: @mcp_url, oauth: provider)
assert_raises(StandardError) do
transport.send_request(request: { jsonrpc: "2.0", id: "1", method: "tools/list" })
end
assert_not_requested(:get, metadata_url)
end
def test_send_request_runs_step_up_flow_on_403_insufficient_scope
# First call with the initial token: 403 carrying an `insufficient_scope`
# Bearer challenge with the escalated scope. The transport must re-run