Skip to content

OAuth discovery security check allows SSRF to private hosts over HTTPS

Moderate
koic published GHSA-4m5j-jjfc-97gx Aug 15, 2026

Package

bundler mcp (RubyGems)

Affected versions

<= 1.1.0

Patched versions

1.2.0

Description

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:

gem install mcp -v 1.0.0

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.

Severity

Moderate

CVE ID

No known CVE

Weaknesses

Server-Side Request Forgery (SSRF)

The web server receives a URL or similar request from an upstream component and retrieves the contents of this URL, but it does not sufficiently ensure that the request is being sent to the expected destination. Learn more on MITRE.

Credits