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

Attachment: OpenPGP_signature.asc
Description: OpenPGP digital signature

Reply via email to