Hello, I claimed ruby-oauth2 in dla-needed.txt. Bookworm has 1.4.4-1, which still sends a bearer token to the host named by a redirect Location. That is CVE-2026-54603 (GHSA-pp92-crg2-gfv9). Unstable is fixed in 2.0.25-1 (upstream 2.0.22, commit 0f0a474). Trixie is a different upstream, 2.0.9, and that fix is a point release rather than this backport. The debdiff against 1.4.4-1 is attached.
1.4.4's Client#request hands the Location string to Faraday 1.1, and that turns a protocol-relative Location (//other-host/...) into a new host while keeping Authorization. The patch resolves that form as a same-origin path, and drops Authorization when the redirect changes scheme, host, or port. A same-origin redirect is unchanged. The spec examples from 0f0a474 are in the patch. One stub is a regexp, because Faraday 1.1 collapses a triple-slash path when the stub is registered and then looks the request up uncollapsed. I ran a local script against the archive package and against this build, on Bookworm's Ruby 3.1 and Faraday 1.1. The archive package sends the token to the host in Location, both for the protocol-relative redirect and for an absolute cross-origin one. This package keeps the protocol-relative request on the original host, removes the token from the absolute redirect, and a normal AccessToken#get and client.request still return the body I expected. The upstream rspec suite is still disabled, same as 1.4.4-1. spec/helper.rb needs silent-stream, which Bookworm does not ship, so autopkgtest's gem2deb-test-runner skips with no test suite. I ran the redirect examples from the patched spec: 10 examples, 0 failures. The rest of the suite passes aside from the OAUTH_DEBUG example, which calls a helper from that missing gem. Bookworm has two packages that depend on ruby-oauth2: ruby-asana and ruby-omniauth-oauth2. The OmniAuth providers in Bookworm (GitHub, GitLab, WordPress, AliCloud, Azure AD, Authentiq, Atlassian, and DingTalk) depend on ruby-omniauth-oauth2, and they get a token through that strategy. Asana's ordinary API calls follow redirects in Faraday, not in OAuth2::Client. The token exchange does go through Client#request, and so does OmniAuth's auth_code.get_token. DingTalk also subclasses OAuth2::Client and calls request() itself. I tested those reverse dependencies against 1.4.4-1+deb12u1. Asana's authorize URL is still on app.asana.com, a same-origin token redirect still comes back with a token, and a protocol-relative token redirect stays on app.asana.com. A client built the way the GitHub strategy builds one does the same. DingTalk's subclass keeps the request, and its Authorization header, on oapi.dingtalk.com. A basic_auth token request, which none of these packages turn on, still follows an absolute cross-origin redirect and drops Authorization. On 1.4.4-1 the protocol-relative calls left the provider, and that basic_auth header went with them. Same-origin redirects and the authorize URLs behaved the same on both packages. I built this in a Bookworm LXD container. You can find a Debusine work request here: https://debusine.debian.net/debian/developers/work-request/1408442/ Feedback is welcome. If nothing showstopping is raised, I plan on uploading this to bookworm-security and issuing the DLA on Monday afternoon (October 5th, 2026). Best regards, Simon Quigley [email protected]
diff --git a/debian/changelog b/debian/changelog index b7f3f9c..40c7048 100644 --- a/debian/changelog +++ b/debian/changelog @@ -1,3 +1,11 @@ +ruby-oauth2 (1.4.4-1+deb12u1) bookworm-security; urgency=medium + + * Team upload. + * CVE-2026-54603: keep a protocol-relative redirect on the same + origin, and drop Authorization when a redirect changes origin. + + -- Simon Quigley <[email protected]> Fri, 02 Oct 2026 11:58:17 -0500 + ruby-oauth2 (1.4.4-1) unstable; urgency=medium [ Utkarsh Gupta ] diff --git a/debian/patches/CVE-2026-54603-protocol-relative-redirect.patch b/debian/patches/CVE-2026-54603-protocol-relative-redirect.patch new file mode 100644 index 0000000..590a4ce --- /dev/null +++ b/debian/patches/CVE-2026-54603-protocol-relative-redirect.patch @@ -0,0 +1,122 @@ +Description: Keep protocol-relative redirects on the same origin + OAuth2::Client#request handed Location to Faraday, and Faraday 1.1 + resolves a protocol-relative Location (//host/...) as a new host. The + bearer token in Authorization was sent there. Resolve those locations + as a same-origin path, and drop Authorization when a redirect changes + scheme, host, or port. + Upstream commit 0f0a474 did this with URI#merge on 2.0.22. 1.4.4 passes + the Location string through, so the same check is applied before the + recursive request. The added spec matches the triple-slash path with a + regexp, because Faraday 1.1 collapses that path when the stub is + registered and then looks the request up uncollapsed. +Origin: upstream, https://github.com/ruby-oauth/oauth2/commit/0f0a474f1b38453e119e660c2daca742d4378ce9 +Bug: https://github.com/ruby-oauth/oauth2/security/advisories/GHSA-pp92-crg2-gfv9 +Bug-Debian: https://bugs.debian.org/1143054 +Author: Peter H. Boling <[email protected]> +Forwarded: not-needed +Last-Update: 2026-10-02 + +--- a/lib/oauth2/client.rb ++++ b/lib/oauth2/client.rb +@@ -111,7 +111,14 @@ module OAuth2 + verb = :get + opts.delete(:body) + end +- request(verb, response.headers['location'], opts) ++ location = response.headers['location'] ++ if location ++ current_location = response.response.env.url ++ full_location = resolve_redirect_location(current_location, location) ++ request(verb, full_location, sanitize_redirect_options(opts, current_location, full_location)) ++ else ++ request(verb, location, opts) ++ end + when 200..299, 300..399 + # on non-redirecting 3xx statuses, just return the response + response +@@ -207,5 +214,37 @@ module OAuth2 + {} + end + end ++ ++ private ++ ++ def resolve_redirect_location(current_location, location) ++ safe_location = ++ if location.respond_to?(:start_with?) && location.start_with?('//') ++ "./#{location}" ++ else ++ location ++ end ++ ++ current_location.merge(safe_location) ++ end ++ ++ def sanitize_redirect_options(req_opts, current_location, next_location) ++ return req_opts unless cross_origin_redirect?(current_location, next_location) ++ ++ headers = req_opts[:headers] ++ return req_opts unless headers && headers.any? { |key, _value| key.to_s.casecmp('Authorization').zero? } ++ ++ safe_opts = req_opts.dup ++ safe_headers = headers.dup ++ safe_headers.delete_if { |key, _value| key.to_s.casecmp('Authorization').zero? } ++ safe_opts[:headers] = safe_headers ++ safe_opts ++ end ++ ++ def cross_origin_redirect?(current_location, next_location) ++ current_location.scheme != next_location.scheme || ++ current_location.host != next_location.host || ++ current_location.port != next_location.port ++ end + end + end +--- a/spec/oauth2/client_spec.rb ++++ b/spec/oauth2/client_spec.rb +@@ -13,6 +13,10 @@ describe OAuth2::Client do + stub.get('/unauthorized') { |env| [401, {'Content-Type' => 'application/json'}, MultiJson.encode(:error => error_value, :error_description => error_description_value)] } + stub.get('/conflict') { |env| [409, {'Content-Type' => 'text/plain'}, 'not authorized'] } + stub.get('/redirect') { |env| [302, {'Content-Type' => 'text/plain', 'location' => '/success'}, ''] } ++ stub.get('/protocol_relative_redirect') { |_env| [302, {'Content-Type' => 'text/plain', 'location' => '//attacker.example/leak'}, ''] } ++ stub.get(%r{\A/{2,}attacker\.example/leak\z}) { |env| [200, {}, MultiJson.encode(:auth => env[:request_headers]['Authorization'], :url => env[:url].to_s)] } ++ stub.get('/absolute_cross_origin_redirect') { |_env| [302, {'Content-Type' => 'text/plain', 'location' => 'https://attacker.example/leak'}, ''] } ++ stub.get('/leak') { |env| [200, {}, MultiJson.encode(:auth => env[:request_headers]['Authorization'], :marker => env[:request_headers]['X-Marker'], :url => env[:url].to_s)] } + stub.post('/redirect') { |env| [303, {'Content-Type' => 'text/plain', 'location' => '/reflect'}, ''] } + stub.get('/error') { |env| [500, {'Content-Type' => 'text/plain'}, 'unknown error'] } + stub.get('/empty_get') { |env| [204, {}, nil] } +@@ -183,6 +187,34 @@ describe OAuth2::Client do + expect(response.headers).to eq('Content-Type' => 'text/awesome') + end + ++ it 'treats protocol-relative redirect locations as same-origin paths' do ++ response = subject.request(:get, '/protocol_relative_redirect', :headers => {'Authorization' => 'Bearer secret'}) ++ body = MultiJson.load(response.body) ++ ++ expect(body['auth']).to eq('Bearer secret') ++ expect(body['url']).to eq('https://api.example.com///attacker.example/leak') ++ expect(response.response.env.url.to_s).to eq('https://api.example.com///attacker.example/leak') ++ end ++ ++ it 'removes Authorization headers from cross-origin redirects' do ++ response = subject.request(:get, '/absolute_cross_origin_redirect', :headers => {'Authorization' => 'Bearer secret', 'X-Marker' => 'kept'}) ++ body = MultiJson.load(response.body) ++ ++ expect(body['auth']).to be_nil ++ expect(body['marker']).to eq('kept') ++ expect(body['url']).to eq('https://attacker.example/leak') ++ expect(response.response.env.url.to_s).to eq('https://attacker.example/leak') ++ end ++ ++ it 'follows cross-origin redirects when there are no credential headers to remove' do ++ response = subject.request(:get, '/absolute_cross_origin_redirect') ++ body = MultiJson.load(response.body) ++ ++ expect(body['auth']).to be_nil ++ expect(body['url']).to eq('https://attacker.example/leak') ++ expect(response.response.env.url.to_s).to eq('https://attacker.example/leak') ++ end ++ + it 'redirects using GET on a 303' do + response = subject.request(:post, '/redirect', :body => 'foo=bar') + expect(response.body).to be_empty diff --git a/debian/patches/series b/debian/patches/series new file mode 100644 index 0000000..899effa --- /dev/null +++ b/debian/patches/series @@ -0,0 +1 @@ +CVE-2026-54603-protocol-relative-redirect.patch
OpenPGP_signature.asc
Description: OpenPGP digital signature
