Fix Mastodon not correctly processing HTTP Signatures with query strings (#28476)
This commit is contained in:
parent
bd415af9a1
commit
092bb8a27a
3 changed files with 95 additions and 4 deletions
|
@ -91,14 +91,23 @@ module SignatureVerification
|
||||||
raise SignatureVerificationError, "Public key not found for key #{signature_params['keyId']}" if actor.nil?
|
raise SignatureVerificationError, "Public key not found for key #{signature_params['keyId']}" if actor.nil?
|
||||||
|
|
||||||
signature = Base64.decode64(signature_params['signature'])
|
signature = Base64.decode64(signature_params['signature'])
|
||||||
compare_signed_string = build_signed_string
|
compare_signed_string = build_signed_string(include_query_string: true)
|
||||||
|
|
||||||
return actor unless verify_signature(actor, signature, compare_signed_string).nil?
|
return actor unless verify_signature(actor, signature, compare_signed_string).nil?
|
||||||
|
|
||||||
|
# Compatibility quirk with older Mastodon versions
|
||||||
|
compare_signed_string = build_signed_string(include_query_string: false)
|
||||||
|
return actor unless verify_signature(actor, signature, compare_signed_string).nil?
|
||||||
|
|
||||||
actor = stoplight_wrap_request { actor_refresh_key!(actor) }
|
actor = stoplight_wrap_request { actor_refresh_key!(actor) }
|
||||||
|
|
||||||
raise SignatureVerificationError, "Could not refresh public key #{signature_params['keyId']}" if actor.nil?
|
raise SignatureVerificationError, "Could not refresh public key #{signature_params['keyId']}" if actor.nil?
|
||||||
|
|
||||||
|
compare_signed_string = build_signed_string(include_query_string: true)
|
||||||
|
return actor unless verify_signature(actor, signature, compare_signed_string).nil?
|
||||||
|
|
||||||
|
# Compatibility quirk with older Mastodon versions
|
||||||
|
compare_signed_string = build_signed_string(include_query_string: false)
|
||||||
return actor unless verify_signature(actor, signature, compare_signed_string).nil?
|
return actor unless verify_signature(actor, signature, compare_signed_string).nil?
|
||||||
|
|
||||||
fail_with! "Verification failed for #{actor.to_log_human_identifier} #{actor.uri} using rsa-sha256 (RSASSA-PKCS1-v1_5 with SHA-256)", signed_string: compare_signed_string, signature: signature_params['signature']
|
fail_with! "Verification failed for #{actor.to_log_human_identifier} #{actor.uri} using rsa-sha256 (RSASSA-PKCS1-v1_5 with SHA-256)", signed_string: compare_signed_string, signature: signature_params['signature']
|
||||||
|
@ -180,11 +189,18 @@ module SignatureVerification
|
||||||
nil
|
nil
|
||||||
end
|
end
|
||||||
|
|
||||||
def build_signed_string
|
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 Request::REQUEST_TARGET
|
||||||
|
if include_query_string
|
||||||
|
"#{Request::REQUEST_TARGET}: #{request.method.downcase} #{request.original_fullpath}"
|
||||||
|
else
|
||||||
|
# Current versions of Mastodon incorrectly omit the query string from the (request-target) pseudo-header.
|
||||||
|
# Therefore, temporarily support such incorrect signatures for compatibility.
|
||||||
|
# TODO: remove eventually some time after release of the fixed version
|
||||||
"#{Request::REQUEST_TARGET}: #{request.method.downcase} #{request.path}"
|
"#{Request::REQUEST_TARGET}: #{request.method.downcase} #{request.path}"
|
||||||
|
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'
|
||||||
raise SignatureVerificationError, 'Pseudo-header (created) used but corresponding argument missing' if signature_params['created'].blank?
|
raise SignatureVerificationError, 'Pseudo-header (created) used but corresponding argument missing' if signature_params['created'].blank?
|
||||||
|
|
|
@ -77,6 +77,7 @@ class Request
|
||||||
@url = Addressable::URI.parse(url).normalize
|
@url = Addressable::URI.parse(url).normalize
|
||||||
@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(:with_query_string)
|
||||||
@options = options.merge(socket_class: use_proxy? || @allow_local ? ProxySocket : Socket)
|
@options = options.merge(socket_class: use_proxy? || @allow_local ? ProxySocket : Socket)
|
||||||
@options = @options.merge(timeout_class: PerOperationWithDeadline, timeout_options: TIMEOUT)
|
@options = @options.merge(timeout_class: PerOperationWithDeadline, timeout_options: TIMEOUT)
|
||||||
@options = @options.merge(proxy_url) if use_proxy?
|
@options = @options.merge(proxy_url) if use_proxy?
|
||||||
|
@ -146,7 +147,7 @@ class Request
|
||||||
private
|
private
|
||||||
|
|
||||||
def set_common_headers!
|
def set_common_headers!
|
||||||
@headers[REQUEST_TARGET] = "#{@verb} #{@url.path}"
|
@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
|
||||||
|
@ -157,6 +158,14 @@ 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'
|
algorithm = 'rsa-sha256'
|
||||||
signature = Base64.strict_encode64(@keypair.sign(OpenSSL::Digest.new('SHA256'), signed_string))
|
signature = Base64.strict_encode64(@keypair.sign(OpenSSL::Digest.new('SHA256'), signed_string))
|
||||||
|
|
|
@ -94,6 +94,72 @@ describe 'signature verification concern' do
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
context 'with a valid signature on a GET request that has a query string' do
|
||||||
|
let(:signature_header) do
|
||||||
|
'keyId="https://remote.domain/users/bob#main-key",algorithm="rsa-sha256",headers="date host (request-target)",signature="SDMa4r/DQYMXYxVgYO2yEqGWWUXugKjVuz0I8dniQAk+aunzBaF2aPu+4grBfawAshlx1Xytl8lhb0H2MllEz16/tKY7rUrb70MK0w8ohXgpb0qs3YvQgdj4X24L1x2MnkFfKHR/J+7TBlnivq0HZqXm8EIkPWLv+eQxu8fbowLwHIVvRd/3t6FzvcfsE0UZKkoMEX02542MhwSif6cu7Ec/clsY9qgKahb9JVGOGS1op9Lvg/9y1mc8KCgD83U5IxVygYeYXaVQ6gixA9NgZiTCwEWzHM5ELm7w5hpdLFYxYOHg/3G3fiqJzpzNQAcCD4S4JxfE7hMI0IzVlNLT6A=="' # rubocop:disable Layout/LineLength
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'successfuly verifies signature', :aggregate_failures do
|
||||||
|
expect(signature_header).to eq build_signature_string(actor_keypair, 'https://remote.domain/users/bob#main-key', 'get /activitypub/success?foo=42', { 'Date' => 'Wed, 20 Dec 2023 10:00:00 GMT', 'Host' => 'www.example.com' })
|
||||||
|
|
||||||
|
get '/activitypub/success?foo=42', headers: {
|
||||||
|
'Host' => 'www.example.com',
|
||||||
|
'Date' => 'Wed, 20 Dec 2023 10:00:00 GMT',
|
||||||
|
'Signature' => signature_header,
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(response).to have_http_status(200)
|
||||||
|
expect(body_as_json).to match(
|
||||||
|
signed_request: true,
|
||||||
|
signature_actor_id: actor.id.to_s
|
||||||
|
)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'when the query string is missing from the signature verification (compatibility quirk)' do
|
||||||
|
let(:signature_header) do
|
||||||
|
'keyId="https://remote.domain/users/bob#main-key",algorithm="rsa-sha256",headers="date host (request-target)",signature="Z8ilar3J7bOwqZkMp7sL8sRs4B1FT+UorbmvWoE+A5UeoOJ3KBcUmbsh+k3wQwbP5gMNUrra9rEWabpasZGphLsbDxfbsWL3Cf0PllAc7c1c7AFEwnewtExI83/qqgEkfWc2z7UDutXc2NfgAx89Ox8DXU/fA2GG0jILjB6UpFyNugkY9rg6oI31UnvfVi3R7sr3/x8Ea3I9thPvqI2byF6cojknSpDAwYzeKdngX3TAQEGzFHz3SDWwyp3jeMWfwvVVbM38FxhvAnSumw7YwWW4L7M7h4M68isLimoT3yfCn2ucBVL5Dz8koBpYf/40w7QidClAwCafZQFC29yDOg=="' # rubocop:disable Layout/LineLength
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'successfuly verifies signature', :aggregate_failures do
|
||||||
|
expect(signature_header).to eq build_signature_string(actor_keypair, 'https://remote.domain/users/bob#main-key', 'get /activitypub/success', { 'Date' => 'Wed, 20 Dec 2023 10:00:00 GMT', 'Host' => 'www.example.com' })
|
||||||
|
|
||||||
|
get '/activitypub/success?foo=42', headers: {
|
||||||
|
'Host' => 'www.example.com',
|
||||||
|
'Date' => 'Wed, 20 Dec 2023 10:00:00 GMT',
|
||||||
|
'Signature' => signature_header,
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(response).to have_http_status(200)
|
||||||
|
expect(body_as_json).to match(
|
||||||
|
signed_request: true,
|
||||||
|
signature_actor_id: actor.id.to_s
|
||||||
|
)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'with mismatching query string' do
|
||||||
|
let(:signature_header) do
|
||||||
|
'keyId="https://remote.domain/users/bob#main-key",algorithm="rsa-sha256",headers="date host (request-target)",signature="SDMa4r/DQYMXYxVgYO2yEqGWWUXugKjVuz0I8dniQAk+aunzBaF2aPu+4grBfawAshlx1Xytl8lhb0H2MllEz16/tKY7rUrb70MK0w8ohXgpb0qs3YvQgdj4X24L1x2MnkFfKHR/J+7TBlnivq0HZqXm8EIkPWLv+eQxu8fbowLwHIVvRd/3t6FzvcfsE0UZKkoMEX02542MhwSif6cu7Ec/clsY9qgKahb9JVGOGS1op9Lvg/9y1mc8KCgD83U5IxVygYeYXaVQ6gixA9NgZiTCwEWzHM5ELm7w5hpdLFYxYOHg/3G3fiqJzpzNQAcCD4S4JxfE7hMI0IzVlNLT6A=="' # rubocop:disable Layout/LineLength
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'fails to verify signature', :aggregate_failures do
|
||||||
|
expect(signature_header).to eq build_signature_string(actor_keypair, 'https://remote.domain/users/bob#main-key', 'get /activitypub/success?foo=42', { 'Date' => 'Wed, 20 Dec 2023 10:00:00 GMT', 'Host' => 'www.example.com' })
|
||||||
|
|
||||||
|
get '/activitypub/success?foo=43', headers: {
|
||||||
|
'Host' => 'www.example.com',
|
||||||
|
'Date' => 'Wed, 20 Dec 2023 10:00:00 GMT',
|
||||||
|
'Signature' => signature_header,
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(body_as_json).to match(
|
||||||
|
signed_request: true,
|
||||||
|
signature_actor_id: nil,
|
||||||
|
error: anything
|
||||||
|
)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
context 'with a mismatching path' do
|
context 'with a mismatching path' do
|
||||||
it 'fails to verify signature', :aggregate_failures do
|
it 'fails to verify signature', :aggregate_failures do
|
||||||
get '/activitypub/alternative-path', headers: {
|
get '/activitypub/alternative-path', headers: {
|
||||||
|
|
Loading…
Reference in a new issue