Improve error reporting and logging when processing remote accounts (#15605)

* Add a more descriptive PrivateNetworkAddressError exception class

* Remove unnecessary exception class to rescue clause

* Remove unnecessary include to JsonLdHelper

* Give more neutral error message when too many webfinger redirects

* Remove unnecessary guard condition

* Rework how “ActivityPub::FetchRemoteAccountService” handles errors

Add “suppress_errors” keyword argument to avoid raising errors in
ActivityPub::FetchRemoteAccountService#call (default/previous behavior).

* Rework how “ActivityPub::FetchRemoteKeyService” handles errors

Add “suppress_errors” keyword argument to avoid raising errors in
ActivityPub::FetchRemoteKeyService#call (default/previous behavior).

* Fix Webfinger::RedirectError not being a subclass of Webfinger::Error

* Add suppress_errors option to ResolveAccountService

Defaults to true (to preserve previous behavior). If set to false,
errors will be raised instead of caught, allowing the caller to be
informed of what went wrong.

* Return more precise error when failing to fetch account signing AP payloads

* Add tests

* Fixes

* Refactor error handling a bit

* Fix various issues

* Add specific error when provided Digest is not 256 bits of base64-encoded data

* Please CodeClimate

* Improve webfinger error reporting
lolsob-rspec
Claire 2022-09-20 23:30:26 +02:00 committed by GitHub
parent 8c412fcded
commit b8853ddcb9
10 changed files with 158 additions and 47 deletions

View File

@ -93,11 +93,15 @@ module SignatureVerification
return account unless verify_signature(account, signature, compare_signed_string).nil?
@signature_verification_failure_reason = "Verification failed for #{account.username}@#{account.domain} #{account.uri} using rsa-sha256 (RSASSA-PKCS1-v1_5 with SHA-256)"
@signed_request_account = nil
fail_with! "Verification failed for #{account.username}@#{account.domain} #{account.uri} using rsa-sha256 (RSASSA-PKCS1-v1_5 with SHA-256)"
rescue SignatureVerificationError => e
@signature_verification_failure_reason = e.message
@signed_request_account = nil
fail_with! e.message
rescue HTTP::Error, OpenSSL::SSL::SSLError => e
fail_with! "Failed to fetch remote data: #{e.message}"
rescue Mastodon::UnexptectedResponseError
fail_with! 'Failed to fetch remote data (got unexpected reply from server)'
rescue Stoplight::Error::RedLight
fail_with! 'Fetching attempt skipped because of recent connection failure'
end
def request_body
@ -106,6 +110,11 @@ module SignatureVerification
private
def fail_with!(message)
@signature_verification_failure_reason = message
@signed_request_account = nil
end
def signature_params
@signature_params ||= begin
raw_signature = request.headers['Signature']
@ -138,7 +147,17 @@ module SignatureVerification
digests = request.headers['Digest'].split(',').map { |digest| digest.split('=', 2) }.map { |key, value| [key.downcase, value] }
sha256 = digests.assoc('sha-256')
raise SignatureVerificationError, "Mastodon only supports SHA-256 in Digest header. Offered algorithms: #{digests.map(&:first).join(', ')}" if sha256.nil?
raise SignatureVerificationError, "Invalid Digest value. Computed SHA-256 digest: #{body_digest}; given: #{sha256[1]}" if body_digest != sha256[1]
return if body_digest == sha256[1]
digest_size = begin
Base64.strict_decode64(sha256[1].strip).length
rescue ArgumentError
raise SignatureVerificationError, "Invalid Digest value. The provided Digest value is not a valid base64 string. Given digest: #{sha256[1]}"
end
raise SignatureVerificationError, "Invalid Digest value. The provided Digest value is not a SHA-256 digest. Given digest: #{sha256[1]}" if digest_size != 32
raise SignatureVerificationError, "Invalid Digest value. Computed SHA-256 digest: #{body_digest}; given: #{sha256[1]}"
end
def verify_signature(account, signature, compare_signed_string)
@ -216,19 +235,20 @@ module SignatureVerification
end
if key_id.start_with?('acct:')
stoplight_wrap_request { ResolveAccountService.new.call(key_id.gsub(/\Aacct:/, '')) }
stoplight_wrap_request { ResolveAccountService.new.call(key_id.gsub(/\Aacct:/, ''), suppress_errors: false) }
elsif !ActivityPub::TagManager.instance.local_uri?(key_id)
account = ActivityPub::TagManager.instance.uri_to_resource(key_id, Account)
account ||= stoplight_wrap_request { ActivityPub::FetchRemoteKeyService.new.call(key_id, id: false) }
account ||= stoplight_wrap_request { ActivityPub::FetchRemoteKeyService.new.call(key_id, id: false, suppress_errors: false) }
account
end
rescue Mastodon::HostValidationError
nil
rescue Mastodon::PrivateNetworkAddressError => e
raise SignatureVerificationError, "Requests to private network addresses are disallowed (tried to query #{e.host})"
rescue Mastodon::HostValidationError, ActivityPub::FetchRemoteAccountService::Error, ActivityPub::FetchRemoteKeyService::Error, Webfinger::Error => e
raise SignatureVerificationError, e.message
end
def stoplight_wrap_request(&block)
Stoplight("source:#{request.remote_ip}", &block)
.with_fallback { nil }
.with_threshold(1)
.with_cool_off_time(5.minutes.seconds)
.with_error_handler { |error, handle| error.is_a?(HTTP::Error) || error.is_a?(OpenSSL::SSL::SSLError) ? handle.call(error) : raise(error) }
@ -237,6 +257,10 @@ module SignatureVerification
def account_refresh_key(account)
return if account.local? || !account.activitypub?
ActivityPub::FetchRemoteAccountService.new.call(account.uri, only_key: true)
ActivityPub::FetchRemoteAccountService.new.call(account.uri, only_key: true, suppress_errors: false)
rescue Mastodon::PrivateNetworkAddressError => e
raise SignatureVerificationError, "Requests to private network addresses are disallowed (tried to query #{e.host})"
rescue Mastodon::HostValidationError, ActivityPub::FetchRemoteAccountService::Error, Webfinger::Error => e
raise SignatureVerificationError, e.message
end
end

View File

@ -208,7 +208,7 @@ class Request
addresses.each do |address|
begin
check_private_address(address)
check_private_address(address, host)
sock = ::Socket.new(address.is_a?(Resolv::IPv6) ? ::Socket::AF_INET6 : ::Socket::AF_INET, ::Socket::SOCK_STREAM, 0)
sockaddr = ::Socket.pack_sockaddr_in(port, address.to_s)
@ -264,10 +264,10 @@ class Request
alias new open
def check_private_address(address)
def check_private_address(address, host)
addr = IPAddr.new(address.to_s)
return if private_address_exceptions.any? { |range| range.include?(addr) }
raise Mastodon::HostValidationError if PrivateAddressCheck.private_address?(addr)
raise Mastodon::PrivateNetworkAddressError, host if PrivateAddressCheck.private_address?(addr)
end
def private_address_exceptions

View File

@ -3,7 +3,7 @@
class Webfinger
class Error < StandardError; end
class GoneError < Error; end
class RedirectError < StandardError; end
class RedirectError < Error; end
class Response
attr_reader :uri

View File

@ -5,10 +5,12 @@ class ActivityPub::FetchRemoteAccountService < BaseService
include DomainControlHelper
include WebfingerHelper
class Error < StandardError; end
SUPPORTED_TYPES = %w(Application Group Organization Person Service).freeze
# Does a WebFinger roundtrip on each call, unless `only_key` is true
def call(uri, id: true, prefetched_body: nil, break_on_redirect: false, only_key: false)
def call(uri, id: true, prefetched_body: nil, break_on_redirect: false, only_key: false, suppress_errors: true)
return if domain_not_allowed?(uri)
return ActivityPub::TagManager.instance.uri_to_resource(uri, Account) if ActivityPub::TagManager.instance.local_uri?(uri)
@ -18,38 +20,50 @@ class ActivityPub::FetchRemoteAccountService < BaseService
else
body_to_json(prefetched_body, compare_id: id ? uri : nil)
end
rescue Oj::ParseError
raise Error, "Error parsing JSON-LD document #{uri}"
end
return if !supported_context? || !expected_type? || (break_on_redirect && @json['movedTo'].present?)
raise Error, "Error fetching actor JSON at #{uri}" if @json.nil?
raise Error, "Unsupported JSON-LD context for document #{uri}" unless supported_context?
raise Error, "Unexpected object type for actor #{uri} (expected any of: #{SUPPORTED_TYPES})" unless expected_type?
raise Error, "Actor #{uri} has moved to #{@json['movedTo']}" if break_on_redirect && @json['movedTo'].present?
@uri = @json['id']
@username = @json['preferredUsername']
@domain = Addressable::URI.parse(@uri).normalized_host
return unless only_key || verified_webfinger?
check_webfinger! unless only_key
ActivityPub::ProcessAccountService.new.call(@username, @domain, @json, only_key: only_key, verified_webfinger: !only_key)
rescue Oj::ParseError
nil
rescue Error => e
Rails.logger.debug "Fetching account #{uri} failed: #{e.message}"
raise unless suppress_errors
end
private
def verified_webfinger?
def check_webfinger!
webfinger = webfinger!("acct:#{@username}@#{@domain}")
confirmed_username, confirmed_domain = split_acct(webfinger.subject)
return webfinger.link('self', 'href') == @uri if @username.casecmp(confirmed_username).zero? && @domain.casecmp(confirmed_domain).zero?
if @username.casecmp(confirmed_username).zero? && @domain.casecmp(confirmed_domain).zero?
raise Error, "Webfinger response for #{@username}@#{@domain} does not loop back to #{@uri}" if webfinger.link('self', 'href') != @uri
return
end
webfinger = webfinger!("acct:#{confirmed_username}@#{confirmed_domain}")
@username, @domain = split_acct(webfinger.subject)
return false unless @username.casecmp(confirmed_username).zero? && @domain.casecmp(confirmed_domain).zero?
return false if webfinger.link('self', 'href') != @uri
unless confirmed_username.casecmp(@username).zero? && confirmed_domain.casecmp(@domain).zero?
raise Webfinger::RedirectError, "Too many webfinger redirects for URI #{uri} (stopped at #{@username}@#{@domain})"
end
true
rescue Webfinger::Error
false
raise Error, "Webfinger response for #{@username}@#{@domain} does not loop back to #{@uri}" if webfinger.link('self', 'href') != @uri
rescue Webfinger::RedirectError => e
raise Error, e.message
rescue Webfinger::Error => e
raise Error, "Webfinger error when resolving #{@username}@#{@domain}: #{e.message}"
end
def split_acct(acct)

View File

@ -3,9 +3,11 @@
class ActivityPub::FetchRemoteKeyService < BaseService
include JsonLdHelper
class Error < StandardError; end
# Returns account that owns the key
def call(uri, id: true, prefetched_body: nil)
return if uri.blank?
def call(uri, id: true, prefetched_body: nil, suppress_errors: true)
raise Error, 'No key URI given' if uri.blank?
if prefetched_body.nil?
if id
@ -13,7 +15,7 @@ class ActivityPub::FetchRemoteKeyService < BaseService
if person?
@json = fetch_resource(@json['id'], true)
elsif uri != @json['id']
return
raise Error, "Fetched URI #{uri} has wrong id #{@json['id']}"
end
else
@json = fetch_resource(uri, id)
@ -22,21 +24,29 @@ class ActivityPub::FetchRemoteKeyService < BaseService
@json = body_to_json(prefetched_body, compare_id: id ? uri : nil)
end
return unless supported_context?(@json) && expected_type?
return find_account(@json['id'], @json) if person?
raise Error, "Unable to fetch key JSON at #{uri}" if @json.nil?
raise Error, "Unsupported JSON-LD context for document #{uri}" unless supported_context?(@json)
raise Error, "Unexpected object type for key #{uri}" unless expected_type?
return find_account(@json['id'], @json, suppress_errors) if person?
@owner = fetch_resource(owner_uri, true)
return unless supported_context?(@owner) && confirmed_owner?
raise Error, "Unable to fetch actor JSON #{owner_uri}" if @owner.nil?
raise Error, "Unsupported JSON-LD context for document #{owner_uri}" unless supported_context?(@owner)
raise Error, "Unexpected object type for actor #{owner_uri} (expected any of: #{SUPPORTED_TYPES})" unless expected_owner_type?
raise Error, "publicKey id for #{owner_uri} does not correspond to #{@json['id']}" unless confirmed_owner?
find_account(owner_uri, @owner)
find_account(owner_uri, @owner, suppress_errors)
rescue Error => e
Rails.logger.debug "Fetching key #{uri} failed: #{e.message}"
raise unless suppress_errors
end
private
def find_account(uri, prefetched_body)
def find_account(uri, prefetched_body, suppress_errors)
account = ActivityPub::TagManager.instance.uri_to_resource(uri, Account)
account ||= ActivityPub::FetchRemoteAccountService.new.call(uri, prefetched_body: prefetched_body)
account ||= ActivityPub::FetchRemoteAccountService.new.call(uri, prefetched_body: prefetched_body, suppress_errors: suppress_errors)
account
end
@ -56,7 +66,11 @@ class ActivityPub::FetchRemoteKeyService < BaseService
@owner_uri ||= value_or_id(@json['owner'])
end
def expected_owner_type?
equals_or_includes_any?(@owner['type'], ActivityPub::FetchRemoteAccountService::SUPPORTED_TYPES)
end
def confirmed_owner?
equals_or_includes_any?(@owner['type'], ActivityPub::FetchRemoteAccountService::SUPPORTED_TYPES) && value_or_id(@owner['publicKey']) == @json['id']
value_or_id(@owner['publicKey']) == @json['id']
end
end

View File

@ -32,8 +32,6 @@ class ActivityPub::ProcessAccountService < BaseService
process_duplicate_accounts! if @options[:verified_webfinger]
end
return if @account.nil?
after_protocol_change! if protocol_changed?
after_key_change! if key_changed? && !@options[:signed_with_known_key]
clear_tombstones! if key_changed?

View File

@ -1,7 +1,6 @@
# frozen_string_literal: true
class ResolveAccountService < BaseService
include JsonLdHelper
include DomainControlHelper
include WebfingerHelper
include Redisable
@ -13,6 +12,7 @@ class ResolveAccountService < BaseService
# @param [Hash] options
# @option options [Boolean] :redirected Do not follow further Webfinger redirects
# @option options [Boolean] :skip_webfinger Do not attempt any webfinger query or refreshing account data
# @option options [Boolean] :suppress_errors When failing, return nil instead of raising an error
# @return [Account]
def call(uri, options = {})
return if uri.blank?
@ -52,15 +52,15 @@ class ResolveAccountService < BaseService
# either needs to be created, or updated from fresh data
fetch_account!
rescue Webfinger::Error, Oj::ParseError => e
rescue Webfinger::Error => e
Rails.logger.debug "Webfinger query for #{@uri} failed: #{e}"
nil
raise unless @options[:suppress_errors]
end
private
def process_options!(uri, options)
@options = options
@options = { suppress_errors: true }.merge(options)
if uri.is_a?(Account)
@account = uri
@ -96,7 +96,7 @@ class ResolveAccountService < BaseService
@username, @domain = split_acct(@webfinger.subject)
unless confirmed_username.casecmp(@username).zero? && confirmed_domain.casecmp(@domain).zero?
raise Webfinger::RedirectError, "The URI #{uri} tries to hijack #{@username}@#{@domain}"
raise Webfinger::RedirectError, "Too many webfinger redirects for URI #{uri} (stopped at #{@username}@#{@domain})"
end
rescue Webfinger::GoneError
@gone = true
@ -110,7 +110,7 @@ class ResolveAccountService < BaseService
return unless activitypub_ready?
with_lock("resolve:#{@username}@#{@domain}") do
@account = ActivityPub::FetchRemoteAccountService.new.call(actor_url)
@account = ActivityPub::FetchRemoteAccountService.new.call(actor_url, suppress_errors: @options[:suppress_errors])
end
@account

View File

@ -25,4 +25,13 @@ module Mastodon
end
end
end
class PrivateNetworkAddressError < HostValidationError
attr_reader :host
def initialize(host)
@host = host
super()
end
end
end

View File

@ -119,6 +119,58 @@ RSpec.describe ActivityPub::FetchRemoteAccountService, type: :service do
include_examples 'sets profile data'
end
context 'when WebFinger returns a different URI' do
let!(:webfinger) { { subject: 'acct:alice@example.com', links: [{ rel: 'self', href: 'https://example.com/bob' }] } }
before do
stub_request(:get, 'https://example.com/alice').to_return(body: Oj.dump(actor))
stub_request(:get, 'https://example.com/.well-known/webfinger?resource=acct:alice@example.com').to_return(body: Oj.dump(webfinger), headers: { 'Content-Type': 'application/jrd+json' })
end
it 'fetches resource' do
account
expect(a_request(:get, 'https://example.com/alice')).to have_been_made.once
end
it 'looks up webfinger' do
account
expect(a_request(:get, 'https://example.com/.well-known/webfinger?resource=acct:alice@example.com')).to have_been_made.once
end
it 'does not create account' do
expect(account).to be_nil
end
end
context 'when WebFinger returns a different URI after a redirection' do
let!(:webfinger) { { subject: 'acct:alice@iscool.af', links: [{ rel: 'self', href: 'https://example.com/bob' }] } }
before do
stub_request(:get, 'https://example.com/alice').to_return(body: Oj.dump(actor))
stub_request(:get, 'https://example.com/.well-known/webfinger?resource=acct:alice@example.com').to_return(body: Oj.dump(webfinger), headers: { 'Content-Type': 'application/jrd+json' })
stub_request(:get, 'https://iscool.af/.well-known/webfinger?resource=acct:alice@iscool.af').to_return(body: Oj.dump(webfinger), headers: { 'Content-Type': 'application/jrd+json' })
end
it 'fetches resource' do
account
expect(a_request(:get, 'https://example.com/alice')).to have_been_made.once
end
it 'looks up webfinger' do
account
expect(a_request(:get, 'https://example.com/.well-known/webfinger?resource=acct:alice@example.com')).to have_been_made.once
end
it 'looks up "redirected" webfinger' do
account
expect(a_request(:get, 'https://iscool.af/.well-known/webfinger?resource=acct:alice@iscool.af')).to have_been_made.once
end
it 'does not create account' do
expect(account).to be_nil
end
end
context 'with wrong id' do
it 'does not create account' do
expect(subject.call('https://fake.address/@foo', prefetched_body: Oj.dump(actor))).to be_nil

View File

@ -137,8 +137,8 @@ RSpec.describe ResolveAccountService, type: :service do
stub_request(:get, 'https://evil.example.com/.well-known/webfinger?resource=acct:foo@evil.example.com').to_return(body: Oj.dump(webfinger2), headers: { 'Content-Type': 'application/jrd+json' })
end
it 'returns new remote account' do
expect { subject.call('Foo@redirected.example.com') }.to raise_error Webfinger::RedirectError
it 'does not return a new remote account' do
expect(subject.call('Foo@redirected.example.com')).to be_nil
end
end