Fix incorrect signature after HTTP redirect (#33757)
Some checks are pending
Check i18n / check-i18n (push) Waiting to run
CodeQL / Analyze (javascript) (push) Waiting to run
CodeQL / Analyze (ruby) (push) Waiting to run
Check formatting / lint (push) Waiting to run
Ruby Linting / lint (push) Waiting to run
Historical data migration test / test (14-alpine) (push) Waiting to run
Historical data migration test / test (15-alpine) (push) Waiting to run
Historical data migration test / test (16-alpine) (push) Waiting to run
Historical data migration test / test (17-alpine) (push) Waiting to run
Ruby Testing / build (production) (push) Waiting to run
Ruby Testing / build (test) (push) Waiting to run
Ruby Testing / test (.ruby-version) (push) Blocked by required conditions
Ruby Testing / test (3.2) (push) Blocked by required conditions
Ruby Testing / test (3.3) (push) Blocked by required conditions
Ruby Testing / Libvips tests (.ruby-version) (push) Blocked by required conditions
Ruby Testing / Libvips tests (3.2) (push) Blocked by required conditions
Ruby Testing / Libvips tests (3.3) (push) Blocked by required conditions
Ruby Testing / End to End testing (.ruby-version) (push) Blocked by required conditions
Ruby Testing / End to End testing (3.2) (push) Blocked by required conditions
Ruby Testing / End to End testing (3.3) (push) Blocked by required conditions
Ruby Testing / Elastic Search integration testing (.ruby-version, docker.elastic.co/elasticsearch/elasticsearch:7.17.13) (push) Blocked by required conditions
Ruby Testing / Elastic Search integration testing (.ruby-version, docker.elastic.co/elasticsearch/elasticsearch:8.10.2) (push) Blocked by required conditions
Ruby Testing / Elastic Search integration testing (.ruby-version, opensearchproject/opensearch:2) (push) Blocked by required conditions
Ruby Testing / Elastic Search integration testing (3.2, docker.elastic.co/elasticsearch/elasticsearch:7.17.13) (push) Blocked by required conditions
Ruby Testing / Elastic Search integration testing (3.3, docker.elastic.co/elasticsearch/elasticsearch:7.17.13) (push) Blocked by required conditions

This commit is contained in:
Claire 2025-01-28 15:44:27 +01:00 committed by GitHub
parent 32aa83e9d7
commit 5b291fcbe4
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 91 additions and 39 deletions

View File

@ -117,7 +117,7 @@ module SignatureVerification
def verify_signature_strength! def verify_signature_strength!
raise SignatureVerificationError, 'Mastodon requires the Date header or (created) pseudo-header to be signed' unless signed_headers.include?('date') || signed_headers.include?('(created)') raise SignatureVerificationError, 'Mastodon requires the Date header or (created) pseudo-header to be signed' unless signed_headers.include?('date') || signed_headers.include?('(created)')
raise SignatureVerificationError, 'Mastodon requires the Digest header or (request-target) pseudo-header to be signed' unless signed_headers.include?(Request::REQUEST_TARGET) || signed_headers.include?('digest') raise SignatureVerificationError, 'Mastodon requires the Digest header or (request-target) pseudo-header to be signed' unless signed_headers.include?(HttpSignatureDraft::REQUEST_TARGET) || signed_headers.include?('digest')
raise SignatureVerificationError, 'Mastodon requires the Host header to be signed when doing a GET request' if request.get? && !signed_headers.include?('host') raise SignatureVerificationError, 'Mastodon requires the Host header to be signed when doing a GET request' if request.get? && !signed_headers.include?('host')
raise SignatureVerificationError, 'Mastodon requires the Digest header to be signed when doing a POST request' if request.post? && !signed_headers.include?('digest') raise SignatureVerificationError, 'Mastodon requires the Digest header to be signed when doing a POST request' if request.post? && !signed_headers.include?('digest')
end end
@ -155,14 +155,14 @@ module SignatureVerification
def build_signed_string(include_query_string: true) def build_signed_string(include_query_string: true)
signed_headers.map do |signed_header| signed_headers.map do |signed_header|
case signed_header case signed_header
when Request::REQUEST_TARGET when HttpSignatureDraft::REQUEST_TARGET
if include_query_string if include_query_string
"#{Request::REQUEST_TARGET}: #{request.method.downcase} #{request.original_fullpath}" "#{HttpSignatureDraft::REQUEST_TARGET}: #{request.method.downcase} #{request.original_fullpath}"
else else
# Current versions of Mastodon incorrectly omit the query string from the (request-target) pseudo-header. # Current versions of Mastodon incorrectly omit the query string from the (request-target) pseudo-header.
# Therefore, temporarily support such incorrect signatures for compatibility. # Therefore, temporarily support such incorrect signatures for compatibility.
# TODO: remove eventually some time after release of the fixed version # TODO: remove eventually some time after release of the fixed version
"#{Request::REQUEST_TARGET}: #{request.method.downcase} #{request.path}" "#{HttpSignatureDraft::REQUEST_TARGET}: #{request.method.downcase} #{request.path}"
end end
when '(created)' when '(created)'
raise SignatureVerificationError, 'Invalid pseudo-header (created) for rsa-sha256' unless signature_algorithm == 'hs2019' raise SignatureVerificationError, 'Invalid pseudo-header (created) for rsa-sha256' unless signature_algorithm == 'hs2019'

View File

@ -0,0 +1,31 @@
# frozen_string_literal: true
# This implements an older draft of HTTP Signatures:
# https://datatracker.ietf.org/doc/html/draft-cavage-http-signatures
class HttpSignatureDraft
REQUEST_TARGET = '(request-target)'
def initialize(keypair, key_id, full_path: true)
@keypair = keypair
@key_id = key_id
@full_path = full_path
end
def request_target(verb, url)
if url.query.nil? || !@full_path
"#{verb} #{url.path}"
else
"#{verb} #{url.path}?#{url.query}"
end
end
def sign(signed_headers, verb, url)
signed_headers = signed_headers.merge(REQUEST_TARGET => request_target(verb, url))
signed_string = signed_headers.map { |key, value| "#{key.downcase}: #{value}" }.join("\n")
algorithm = 'rsa-sha256'
signature = Base64.strict_encode64(@keypair.sign(OpenSSL::Digest.new('SHA256'), signed_string))
"keyId=\"#{@key_id}\",algorithm=\"#{algorithm}\",headers=\"#{signed_headers.keys.join(' ').downcase}\",signature=\"#{signature}\""
end
end

View File

@ -61,8 +61,6 @@ class PerOperationWithDeadline < HTTP::Timeout::PerOperation
end end
class Request class Request
REQUEST_TARGET = '(request-target)'
# We enforce a 5s timeout on DNS resolving, 5s timeout on socket opening # We enforce a 5s timeout on DNS resolving, 5s timeout on socket opening
# and 5s timeout on the TLS handshake, meaning the worst case should take # and 5s timeout on the TLS handshake, meaning the worst case should take
# about 15s in total # about 15s in total
@ -78,11 +76,18 @@ class Request
@http_client = options.delete(:http_client) @http_client = options.delete(:http_client)
@allow_local = options.delete(:allow_local) @allow_local = options.delete(:allow_local)
@full_path = !options.delete(:omit_query_string) @full_path = !options.delete(:omit_query_string)
@options = options.merge(socket_class: use_proxy? || @allow_local ? ProxySocket : Socket) @options = {
@options = @options.merge(timeout_class: PerOperationWithDeadline, timeout_options: TIMEOUT) follow: {
max_hops: 3,
on_redirect: ->(response, request) { re_sign_on_redirect(response, request) },
},
socket_class: use_proxy? || @allow_local ? ProxySocket : Socket,
}.merge(options)
@options = @options.merge(proxy_url) if use_proxy? @options = @options.merge(proxy_url) if use_proxy?
@headers = {} @headers = {}
@signing = nil
raise Mastodon::HostValidationError, 'Instance does not support hidden service connections' if block_hidden_service? raise Mastodon::HostValidationError, 'Instance does not support hidden service connections' if block_hidden_service?
set_common_headers! set_common_headers!
@ -92,8 +97,9 @@ class Request
def on_behalf_of(actor, sign_with: nil) def on_behalf_of(actor, sign_with: nil)
raise ArgumentError, 'actor must not be nil' if actor.nil? raise ArgumentError, 'actor must not be nil' if actor.nil?
@actor = actor key_id = ActivityPub::TagManager.instance.key_uri_for(actor)
@keypair = sign_with.present? ? OpenSSL::PKey::RSA.new(sign_with) : @actor.keypair keypair = sign_with.present? ? OpenSSL::PKey::RSA.new(sign_with) : actor.keypair
@signing = HttpSignatureDraft.new(keypair, key_id, full_path: @full_path)
self self
end end
@ -119,7 +125,7 @@ class Request
end end
def headers def headers
(@actor ? @headers.merge('Signature' => signature) : @headers).without(REQUEST_TARGET) (@signing ? @headers.merge('Signature' => signature) : @headers)
end end
class << self class << self
@ -134,14 +140,13 @@ class Request
end end
def http_client def http_client
HTTP.use(:auto_inflate).follow(max_hops: 3) HTTP.use(:auto_inflate)
end end
end end
private private
def set_common_headers! def set_common_headers!
@headers[REQUEST_TARGET] = request_target
@headers['User-Agent'] = Mastodon::Version.user_agent @headers['User-Agent'] = Mastodon::Version.user_agent
@headers['Host'] = @url.host @headers['Host'] = @url.host
@headers['Date'] = Time.now.utc.httpdate @headers['Date'] = Time.now.utc.httpdate
@ -152,31 +157,28 @@ class Request
@headers['Digest'] = "SHA-256=#{Digest::SHA256.base64digest(@options[:body])}" @headers['Digest'] = "SHA-256=#{Digest::SHA256.base64digest(@options[:body])}"
end end
def request_target
if @url.query.nil? || !@full_path
"#{@verb} #{@url.path}"
else
"#{@verb} #{@url.path}?#{@url.query}"
end
end
def signature def signature
algorithm = 'rsa-sha256' @signing.sign(@headers.without('User-Agent', 'Accept-Encoding'), @verb, @url)
signature = Base64.strict_encode64(@keypair.sign(OpenSSL::Digest.new('SHA256'), signed_string))
"keyId=\"#{key_id}\",algorithm=\"#{algorithm}\",headers=\"#{signed_headers.keys.join(' ').downcase}\",signature=\"#{signature}\""
end end
def signed_string def re_sign_on_redirect(_response, request)
signed_headers.map { |key, value| "#{key.downcase}: #{value}" }.join("\n") # Delete existing signature if there is one, since it will be invalid
end request.headers.delete('Signature')
def signed_headers return unless @signing.present? && @verb == :get
@headers.without('User-Agent', 'Accept-Encoding')
end
def key_id signed_headers = request.headers.to_h.slice(*@headers.keys)
ActivityPub::TagManager.instance.key_uri_for(@actor) unless @headers.keys.all? { |key| signed_headers.key?(key) }
# We have lost some headers in the process, so don't sign the new
# request, in order to avoid issuing a valid signature with fewer
# conditions than expected.
Rails.logger.warn { "Some headers (#{@headers.keys - signed_headers.keys}) have been lost on redirect from {@uri} to #{request.uri}, this should not happen. Skipping signatures" }
return
end
signature_value = @signing.sign(signed_headers.without('User-Agent', 'Accept-Encoding'), @verb, Addressable::URI.parse(request.uri))
request.headers['Signature'] = signature_value
end end
def http_client def http_client

View File

@ -60,16 +60,12 @@ RSpec.describe Request do
expect(a_request(:get, 'http://example.com')).to have_been_made.once expect(a_request(:get, 'http://example.com')).to have_been_made.once
end end
it 'sets headers' do it 'makes a request with expected headers, yields, and closes the underlying connection' do
expect { |block| subject.perform(&block) }.to yield_control
expect(a_request(:get, 'http://example.com').with(headers: subject.headers)).to have_been_made
end
it 'closes underlying connection' do
allow(subject.send(:http_client)).to receive(:close) allow(subject.send(:http_client)).to receive(:close)
expect { |block| subject.perform(&block) }.to yield_control expect { |block| subject.perform(&block) }.to yield_control
expect(a_request(:get, 'http://example.com').with(headers: subject.headers)).to have_been_made
expect(subject.send(:http_client)).to have_received(:close) expect(subject.send(:http_client)).to have_received(:close)
end end
@ -80,6 +76,29 @@ RSpec.describe Request do
end end
end end
context 'with a redirect and HTTP signatures' do
let(:account) { Fabricate(:account) }
before do
stub_request(:get, 'http://example.com').to_return(status: 301, headers: { Location: 'http://redirected.example.com/foo' })
stub_request(:get, 'http://redirected.example.com/foo').to_return(body: 'lorem ipsum')
end
it 'makes a request with expected headers and follows redirects' do
expect { |block| subject.on_behalf_of(account).perform(&block) }.to yield_control
# request.headers includes the `Signature` sent for the first request
expect(a_request(:get, 'http://example.com').with(headers: subject.headers)).to have_been_made.once
# request.headers includes the `Signature`, but it has changed
expect(a_request(:get, 'http://redirected.example.com/foo').with(headers: subject.headers.merge({ 'Host' => 'redirected.example.com' }))).to_not have_been_made
# `with(headers: )` matching tests for inclusion, so strip `Signature`
# This doesn't actually test that there is a signature, but it tests that the original signature is not passed
expect(a_request(:get, 'http://redirected.example.com/foo').with(headers: subject.headers.without('Signature').merge({ 'Host' => 'redirected.example.com' }))).to have_been_made.once
end
end
context 'with private host' do context 'with private host' do
around do |example| around do |example|
WebMock.disable! WebMock.disable!