Summary
The OAuth client validates the server-controlled resource_metadata URL before fetching it, but the check only requires HTTPS. It does not block private, link-local, or cloud-metadata addresses. A server can return 401 WWW-Authenticate: Bearer resource_metadata="https://169.254.169.254/..." or an internal HTTPS URL, and the client issues a GET to it during discovery. This is a server-side request forgery against internal HTTPS services and cloud metadata endpoints from the client's network position.
The check also reads only the dotted-decimal spelling of an address, so the same destinations stay reachable through the numeric forms the resolver accepts.
Details
The discovery URL comes from the server's WWW-Authenticate header and is gated by a "Communication Security" check that only enforces the HTTPS scheme.
In lib/mcp/client/oauth/discovery.rb, secure_url?:
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
end
Any https URL returns true regardless of host, so https://169.254.169.254/, https://10.0.0.5/, and internal hostnames all pass.
In lib/mcp/client/oauth/flow.rb, run! gates the server-supplied URL with this check and then fetches it:
if resource_metadata_url
ensure_secure_url!(resource_metadata_url, label: "WWW-Authenticate resource_metadata URL")
end
ensure_secure_url! raises only when secure_url? is false, so an internal HTTPS URL passes. protected_resource_metadata_urls places the server-supplied URL first in the discovery list, and fetch_protected_resource_metadata fetches that list, so the internal URL is the first one requested.
There is no check that the URL shares an origin with the MCP server, and no block for loopback, RFC 1918, link-local, or the 169.254.169.254 metadata address.
PoC
Reproduced against the released mcp gem version 1.0.0.
Set up:
Script (poc.rb):
require 'mcp'
require 'mcp/client/oauth/discovery'
D = MCP::Client::OAuth::Discovery
cases = {
"http://10.0.0.5/" => "internal over HTTP",
"https://169.254.169.254/latest/meta-data/" => "cloud metadata over HTTPS",
"https://10.0.0.5/admin" => "internal service over HTTPS",
"https://internal-vault.corp.local/v1/secret" => "internal host over HTTPS",
}
puts "secure_url? gate applied to a server-controlled resource_metadata URL:\n\n"
cases.each do |url, desc|
printf(" %-8s %-45s (%s)\n", D.secure_url?(url) ? "ALLOWED" : "blocked", url, desc)
end
internal = "https://169.254.169.254/latest/meta-data/iam/"
urls = D.protected_resource_metadata_urls(
server_url: "https://legit-mcp.example.com/mcp",
resource_metadata_url: internal,
)
puts "\nFetch order for resource_metadata_url=#{internal}:"
urls.each_with_index { |u, i| puts " #{i}: #{u}" }
Run it: ruby poc.rb. Observed output:
secure_url? gate applied to a server-controlled resource_metadata URL:
blocked http://10.0.0.5/ (internal over HTTP)
ALLOWED https://169.254.169.254/latest/meta-data/ (cloud metadata over HTTPS)
ALLOWED https://10.0.0.5/admin (internal service over HTTPS)
ALLOWED https://internal-vault.corp.local/v1/secret (internal host over HTTPS)
Fetch order for resource_metadata_url=https://169.254.169.254/latest/meta-data/iam/:
0: https://169.254.169.254/latest/meta-data/iam/
1: https://legit-mcp.example.com/.well-known/oauth-protected-resource/mcp
2: https://legit-mcp.example.com/.well-known/oauth-protected-resource
The internal HTTPS URL passes the gate and is the first entry fetched. Against a real network the client sends a GET to that internal address.
Alternate numeric spellings
A range check is only as good as its reading of the host. IPAddr.new accepts the dotted-decimal and IPv6 forms and rejects everything else, while the resolver that the connection actually uses accepts the wider inet_aton grammar: one to four dot-separated parts, each hexadecimal, octal, or decimal, with the last part filling the remaining bytes. Measured via Socket.getaddrinfo on Ruby 4.0.6 (macOS):
| Host |
Resolves to |
2130706433 |
127.0.0.1 |
0x7f000001 |
127.0.0.1 |
127.1 |
127.0.0.1 |
0x7f.0.0.1 |
127.0.0.1 |
3232235777 |
192.168.1.1 |
0xa9fea9fe |
169.254.169.254 |
0251.0376.0251.0376 |
169.254.169.254 |
A fix that refuses https://169.254.169.254 but fetches https://0xa9fea9fe leaves the reported attack intact.
The patched version refuses that grammar outright rather than decoding it and range-checking the result. The 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, so choosing a reading would leave the other one as a way through. No authorization server is legitimately addressed in this notation.
Limits of the fix
Hostnames are not resolved. A lookup would be a second network request driven by the same untrusted input, and the address it returned need not be the one the connection uses a moment later, so the check would read as a guarantee it cannot make. An internal host that is named rather than addressed, such as https://vault.corp.internal/, is therefore not blocked by the range check; what covers the first hop instead is a stricter rule, namely that the resource_metadata URL must be on the MCP server's own origin.
The OAuth connection follows no redirects, so every check applies to the URL that is actually fetched. Egress controls remain deployment guidance rather than something the SDK enforces.
Impact
Server-side request forgery (CWE-918), limited to HTTPS targets. A server the client connects to can make the client send GET requests to internal HTTPS services and cloud metadata endpoints it can route to but the attacker cannot. The request fires automatically during OAuth discovery, before the user approves anything. The response is schema-validated, so this is closer to a blind SSRF than a direct read of the target's body, but it still allows reaching internal HTTPS services, probing reachability, and triggering GET-based side effects. HTTP to non-loopback hosts is already blocked, so the gap is specifically HTTPS to private and metadata addresses.
A suggested fix is to extend the check so it also rejects URLs whose host resolves to loopback, link-local, private, or cloud-metadata ranges, and ideally require the URL to share an origin with the MCP server. The scheme check alone does not prevent SSRF.
Summary
The OAuth client validates the server-controlled
resource_metadataURL before fetching it, but the check only requires HTTPS. It does not block private, link-local, or cloud-metadata addresses. A server can return401 WWW-Authenticate: Bearer resource_metadata="https://169.254.169.254/..."or an internal HTTPS URL, and the client issues a GET to it during discovery. This is a server-side request forgery against internal HTTPS services and cloud metadata endpoints from the client's network position.The check also reads only the dotted-decimal spelling of an address, so the same destinations stay reachable through the numeric forms the resolver accepts.
Details
The discovery URL comes from the server's
WWW-Authenticateheader and is gated by a "Communication Security" check that only enforces the HTTPS scheme.In
lib/mcp/client/oauth/discovery.rb,secure_url?:Any
httpsURL returns true regardless of host, sohttps://169.254.169.254/,https://10.0.0.5/, and internal hostnames all pass.In
lib/mcp/client/oauth/flow.rb,run!gates the server-supplied URL with this check and then fetches it:ensure_secure_url!raises only whensecure_url?is false, so an internal HTTPS URL passes.protected_resource_metadata_urlsplaces the server-supplied URL first in the discovery list, andfetch_protected_resource_metadatafetches that list, so the internal URL is the first one requested.There is no check that the URL shares an origin with the MCP server, and no block for loopback, RFC 1918, link-local, or the
169.254.169.254metadata address.PoC
Reproduced against the released
mcpgem version 1.0.0.Set up:
Script (
poc.rb):Run it:
ruby poc.rb. Observed output:The internal HTTPS URL passes the gate and is the first entry fetched. Against a real network the client sends a GET to that internal address.
Alternate numeric spellings
A range check is only as good as its reading of the host.
IPAddr.newaccepts the dotted-decimal and IPv6 forms and rejects everything else, while the resolver that the connection actually uses accepts the widerinet_atongrammar: one to four dot-separated parts, each hexadecimal, octal, or decimal, with the last part filling the remaining bytes. Measured viaSocket.getaddrinfoon Ruby 4.0.6 (macOS):21307064330x7f000001127.10x7f.0.0.132322357770xa9fea9fe0251.0376.0251.0376A fix that refuses
https://169.254.169.254but fetcheshttps://0xa9fea9feleaves the reported attack intact.The patched version refuses that grammar outright rather than decoding it and range-checking the result. The 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, so choosing a reading would leave the other one as a way through. No authorization server is legitimately addressed in this notation.Limits of the fix
Hostnames are not resolved. A lookup would be a second network request driven by the same untrusted input, and the address it returned need not be the one the connection uses a moment later, so the check would read as a guarantee it cannot make. An internal host that is named rather than addressed, such as
https://vault.corp.internal/, is therefore not blocked by the range check; what covers the first hop instead is a stricter rule, namely that theresource_metadataURL must be on the MCP server's own origin.The OAuth connection follows no redirects, so every check applies to the URL that is actually fetched. Egress controls remain deployment guidance rather than something the SDK enforces.
Impact
Server-side request forgery (CWE-918), limited to HTTPS targets. A server the client connects to can make the client send GET requests to internal HTTPS services and cloud metadata endpoints it can route to but the attacker cannot. The request fires automatically during OAuth discovery, before the user approves anything. The response is schema-validated, so this is closer to a blind SSRF than a direct read of the target's body, but it still allows reaching internal HTTPS services, probing reachability, and triggering GET-based side effects. HTTP to non-loopback hosts is already blocked, so the gap is specifically HTTPS to private and metadata addresses.
A suggested fix is to extend the check so it also rejects URLs whose host resolves to loopback, link-local, private, or cloud-metadata ranges, and ideally require the URL to share an origin with the MCP server. The scheme check alone does not prevent SSRF.