Package: release.debian.org
Control: affects -1 + src:ruby-oauth2
X-Debbugs-Cc: [email protected]
User: [email protected]
Usertags: pu
Tags: trixie
Severity: normal

[ Reason ]
ruby-oauth2 in Trixie is 2.0.9-1, and it's hit by CVE-2026-54603 
(GHSA-pp92-crg2-gfv9). Client#request follows Location with URI#merge. If that 
Location is protocol-relative, the //other-host/... form, the host gets 
replaced and the Authorization header goes with the request, so the bearer 
token ends up at whatever host the redirect named. An absolute URL on another 
origin keeps the header too. The tracker has this as no-dsa, minor, on Trixie. 
Unstable is already fixed in 2.0.25-1, which is upstream 2.0.22, commit 
0f0a474. #1143054 is closed there, so this upload doesn't close it again.

[ Impact ]
If a caller is sending a bearer token with this client, a server that answers 
with Location: //other-host/... or with an absolute URL on another origin gets 
that token forwarded.

[ Tests ]
I turned the upstream rspec suite on. debian/ruby-tests.rake.disabled is now 
debian/ruby-tests.rake, and it runs spec/**/*_spec.rb. The examples all run. 
gem2deb-test-runner in the Trixie build was 462 examples, 0 failures.

The redirect coverage is the three examples from commit 0f0a474, patched into 
spec/oauth2/client_spec.rb. A protocol-relative Location stays on the original 
host and keeps Authorization. An absolute cross-origin redirect is still 
followed, but Authorization comes off. A cross-origin redirect that never had 
that header still follows.

One of the older examples was looking for a particular XML error string. 
Trixie's REXML raises MultiXml::ParseError with a different message, and 
upstream 2.0.25 only checks the class, so I did the same. That patch doesn't 
touch the library. .rspec is still --order random, which is #858254, and I'm 
leaving that bug alone.

I also tried a local script against both packages. On the archive package the 
bearer token was sent to the host named in Location, for the protocol-relative 
redirect and for the absolute one. With this package the protocol-relative 
request stayed on the original host and kept the token, the absolute redirect 
was followed with the token removed, and a normal AccessToken#get and 
client.request still returned the body I expected.

[ Risks ]
This is the 2.0.22 fix, fitted onto 2.0.9's client.rb. Three private methods. A 
normal token request, and a same-origin relative redirect, still behave as they 
do in 2.0.9-1. A protocol-relative Location stays on the original origin. A 
redirect that changes scheme, host, or port is still followed, with 
Authorization removed and the other headers kept. 2.0.23 rewrote that 
protocol-relative handling for Ruby 2.4, which Trixie doesn't need. Trixie is 
Ruby 3.3.8, and the 2.0.22 form already stays on the original host there. Sid's 
2.0.25 does the same thing.

[ Checklist ]
  [x] *all* changes are documented in the d/changelog
  [x] I reviewed all changes and I approve them
  [x] attach debdiff against the package in (old)stable
  [x] the issue is verified as fixed in unstable

[ Changes ]
The debdiff against Trixie 2.0.9-1 is debian/changelog, debian/control, 
debian/patches/series, CVE-2026-54603-protocol-relative-redirect.patch, 
accept-rexml-parse-error.patch, debian/ruby-tests.rake, and the removal of 
debian/ruby-tests.rake.disabled. The CVE patch is the client.rb backport plus 
the three specs from 0f0a474, with the quotes adjusted for 2.0.9. The other 
patch is only that XML assertion. Build-Depends picks up ruby-backports, 
ruby-rspec-block-is-expected, ruby-rspec-pending-for, ruby-rspec-stubbed-env, 
and ruby-silent-stream, all of which are already in Trixie. Version is 
2.0.9-1+deb13u1, urgency=medium, distribution trixie.

Best regards,
Simon Quigley
[email protected]
diff --git a/debian/changelog b/debian/changelog
index 4126c08..77c3a73 100644
--- a/debian/changelog
+++ b/debian/changelog
@@ -1,3 +1,12 @@
+ruby-oauth2 (2.0.9-1+deb13u1) trixie; urgency=medium
+
+  * CVE-2026-54603: keep a protocol-relative redirect on the same
+    origin, and drop Authorization when a redirect changes origin.
+  * Accept REXML's MultiXml::ParseError in the XML parse example.
+  * Run the upstream test suite.
+
+ -- Simon Quigley <[email protected]>  Thu, 01 Oct 2026 21:55:55 -0500
+
 ruby-oauth2 (2.0.9-1) unstable; urgency=medium
 
   * Team upload
diff --git a/debian/control b/debian/control
index 8b3e752..c819cf3 100644
--- a/debian/control
+++ b/debian/control
@@ -7,12 +7,17 @@ Build-Depends: debhelper-compat (= 13),
                gem2deb,
                rake,
                ruby-addressable,
+               ruby-backports,
                ruby-faraday,
                ruby-jwt,
                ruby-multi-json,
                ruby-multi-xml,
                ruby-rack,
                ruby-rspec,
+               ruby-rspec-block-is-expected,
+               ruby-rspec-pending-for,
+               ruby-rspec-stubbed-env,
+               ruby-silent-stream,
                ruby-simplecov,
                ruby-coveralls,
                ruby-rash-alt,
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..4a77f79
--- /dev/null
+++ b/debian/patches/CVE-2026-54603-protocol-relative-redirect.patch
@@ -0,0 +1,112 @@
+Description: Keep protocol-relative redirects on the same origin
+ OAuth2::Client#request followed Location with URI#merge. A
+ protocol-relative Location (//host/...) replaced the request authority, so
+ the Authorization header was sent to that host. Resolve those locations as
+ a same-origin path, and drop Authorization when a redirect changes scheme,
+ host, or port.
+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-01
+
+--- a/lib/oauth2/client.rb
++++ b/lib/oauth2/client.rb
+@@ -125,8 +125,9 @@ module OAuth2
+         end
+         location = response.headers['location']
+         if location
+-          full_location = response.response.env.url.merge(location)
+-          request(verb, full_location, opts)
++          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
+           error = Error.new(response)
+           raise(error, "Got #{response.status} status code, but no Location 
header was present")
+@@ -295,6 +296,36 @@ module OAuth2
+       Response.new(response, parse: parse, snaky: snaky)
+     end
+ 
++    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
++
+     # Returns the authenticator object
+     #
+     # @return [Authenticator] the initialized Authenticator
+--- a/spec/oauth2/client_spec.rb
++++ b/spec/oauth2/client_spec.rb
+@@ -13,6 +13,10 @@ RSpec.describe OAuth2::Client do
+         stub.get('/unauthorized')        { |_env| [401, {'Content-Type' => 
'application/json'}, JSON.dump(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('///attacker.example/leak') { |env| [200, {}, 
JSON.dump(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, {}, JSON.dump(auth: 
env[:request_headers]['Authorization'], marker: 
env[:request_headers]['X-Marker'], url: env[:url].to_s)] }
+         stub.get('/redirect_no_loc')     { |_env| [302, {'Content-Type' => 
'text/plain'}, ''] }
+         stub.post('/redirect')           { |_env| [303, {'Content-Type' => 
'text/plain', 'location' => '/reflect'}, ''] }
+         stub.get('/error')               { |_env| [500, {'Content-Type' => 
'text/plain'}, 'unknown error'] }
+@@ -364,6 +368,34 @@ RSpec.describe OAuth2::Client do
+       expect(response.response.env.url.to_s).to 
eq('https://api.example.com/success')
+     end
+ 
++    it 'treats protocol-relative redirect locations as same-origin paths' do
++      response = subject.request(:get, '/protocol_relative_redirect', 
headers: {'Authorization' => 'Bearer secret'})
++      body = JSON.parse(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 = JSON.parse(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 = JSON.parse(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/accept-rexml-parse-error.patch 
b/debian/patches/accept-rexml-parse-error.patch
new file mode 100644
index 0000000..034d04d
--- /dev/null
+++ b/debian/patches/accept-rexml-parse-error.patch
@@ -0,0 +1,22 @@
+Description: Match MultiXml::ParseError by class
+ The XML example asserts the parser's old error string. Trixie's REXML
+ raises MultiXml::ParseError with a different message. Upstream 2.0.25
+ checks only the exception class.
+Origin: upstream, 
https://github.com/ruby-oauth/oauth2/blob/v2.0.25/spec/oauth2/client_spec.rb
+Forwarded: not-needed
+Last-Update: 2026-10-01
+
+--- a/spec/oauth2/client_spec.rb
++++ b/spec/oauth2/client_spec.rb
+@@ -530,10 +530,7 @@ RSpec.describe OAuth2::Client do
+           end
+         end
+ 
+-        expect { client.get_token(parse: :xml) }.to raise_error(
+-          MultiXml::ParseError,
+-          'The document "{\"access_token\":\"the-token\"}" does not have a 
valid root'
+-        )
++        expect { client.get_token(parse: :xml) }.to 
raise_error(MultiXml::ParseError)
+       end
+     end
+ 
diff --git a/debian/patches/series b/debian/patches/series
new file mode 100644
index 0000000..93f2de5
--- /dev/null
+++ b/debian/patches/series
@@ -0,0 +1,2 @@
+CVE-2026-54603-protocol-relative-redirect.patch
+accept-rexml-parse-error.patch
diff --git a/debian/ruby-tests.rake b/debian/ruby-tests.rake
new file mode 100644
index 0000000..6586b64
--- /dev/null
+++ b/debian/ruby-tests.rake
@@ -0,0 +1,11 @@
+require 'rspec/core/rake_task'
+
+# spec/spec_helper.rb loads codecov and extra SimpleCov formatters unless CI
+# is set. Those libraries are not in trixie. The specs themselves still run.
+ENV['CI'] = 'true'
+
+RSpec::Core::RakeTask.new(:spec) do |spec|
+  spec.pattern = './spec/**/*_spec.rb'
+end
+
+task default: :spec
diff --git a/debian/ruby-tests.rake.disabled b/debian/ruby-tests.rake.disabled
deleted file mode 100644
index 89a753d..0000000
--- a/debian/ruby-tests.rake.disabled
+++ /dev/null
@@ -1,7 +0,0 @@
-require 'rspec/core/rake_task'
-
-RSpec::Core::RakeTask.new(:spec) do |spec|
-  spec.pattern      = './spec/**/*_spec.rb'
-end
-
-task :default => :spec

Attachment: OpenPGP_signature.asc
Description: OpenPGP digital signature

Reply via email to