From 52157fdcba0837c782edbfd240be07cabc551de9 Mon Sep 17 00:00:00 2001 From: Eugen Rochko Date: Sun, 30 Aug 2020 12:34:20 +0200 Subject: [PATCH] Add support for dereferencing objects through bearcaps (#14683) --- app/lib/activitypub/activity.rb | 16 +++-- app/lib/activitypub/activity/create.rb | 22 ++++--- app/lib/activitypub/dereferencer.rb | 69 +++++++++++++++++++++ spec/lib/activitypub/dereferencer_spec.rb | 73 +++++++++++++++++++++++ 4 files changed, 166 insertions(+), 14 deletions(-) create mode 100644 app/lib/activitypub/dereferencer.rb create mode 100644 spec/lib/activitypub/dereferencer_spec.rb diff --git a/app/lib/activitypub/activity.rb b/app/lib/activitypub/activity.rb index a379a7ef43..94aee7939f 100644 --- a/app/lib/activitypub/activity.rb +++ b/app/lib/activitypub/activity.rb @@ -71,7 +71,15 @@ class ActivityPub::Activity end def object_uri - @object_uri ||= value_or_id(@object) + @object_uri ||= begin + str = value_or_id(@object) + + if str.start_with?('bear:') + Addressable::URI.parse(str).query_values['u'] + else + str + end + end end def unsupported_object_type? @@ -159,12 +167,10 @@ class ActivityPub::Activity def dereference_object! return unless @object.is_a?(String) - return if invalid_origin?(@object) - object = fetch_resource(@object, true, signed_fetch_account) - return unless object.present? && object.is_a?(Hash) && supported_context?(object) + dereferencer = ActivityPub::Dereferencer.new(@object, permitted_origin: @account.uri, signature_account: signed_fetch_account) - @object = object + @object = dereferencer.object unless dereferencer.object.nil? end def signed_fetch_account diff --git a/app/lib/activitypub/activity/create.rb b/app/lib/activitypub/activity/create.rb index a60b79d159..f275feefc5 100644 --- a/app/lib/activitypub/activity/create.rb +++ b/app/lib/activitypub/activity/create.rb @@ -15,7 +15,7 @@ class ActivityPub::Activity::Create < ActivityPub::Activity private def create_encrypted_message - return reject_payload! if invalid_origin?(@object['id']) || @options[:delivered_to_account_id].blank? + return reject_payload! if invalid_origin?(object_uri) || @options[:delivered_to_account_id].blank? target_account = Account.find(@options[:delivered_to_account_id]) target_device = target_account.devices.find_by(device_id: @object.dig('to', 'deviceId')) @@ -43,7 +43,7 @@ class ActivityPub::Activity::Create < ActivityPub::Activity end def create_status - return reject_payload! if unsupported_object_type? || invalid_origin?(@object['id']) || Tombstone.exists?(uri: @object['id']) || !related_to_local_activity? + return reject_payload! if unsupported_object_type? || invalid_origin?(object_uri) || tombstone_exists? || !related_to_local_activity? RedisLock.acquire(lock_options) do |lock| if lock.acquired? @@ -90,7 +90,7 @@ class ActivityPub::Activity::Create < ActivityPub::Activity fetch_replies(@status) check_for_spam distribute(@status) - forward_for_reply if @status.distributable? + forward_for_reply end def find_existing_status @@ -102,8 +102,8 @@ class ActivityPub::Activity::Create < ActivityPub::Activity def process_status_params @params = begin { - uri: @object['id'], - url: object_url || @object['id'], + uri: object_uri, + url: object_url || object_uri, account: @account, text: text_from_content || '', language: detected_language, @@ -313,7 +313,7 @@ class ActivityPub::Activity::Create < ActivityPub::Activity RedisLock.acquire(poll_lock_options) do |lock| if lock.acquired? already_voted = poll.votes.where(account: @account).exists? - poll.votes.create!(account: @account, choice: poll.options.index(@object['name']), uri: @object['id']) + poll.votes.create!(account: @account, choice: poll.options.index(@object['name']), uri: object_uri) else raise Mastodon::RaceConditionError end @@ -385,7 +385,7 @@ class ActivityPub::Activity::Create < ActivityPub::Activity end def text_from_content - return Formatter.instance.linkify([[text_from_name, text_from_summary.presence].compact.join("\n\n"), object_url || @object['id']].join(' ')) if converted_object_type? + return Formatter.instance.linkify([[text_from_name, text_from_summary.presence].compact.join("\n\n"), object_url || object_uri].join(' ')) if converted_object_type? if @object['content'].present? @object['content'] @@ -484,12 +484,16 @@ class ActivityPub::Activity::Create < ActivityPub::Activity Account.local.where(username: local_usernames).exists? end + def tombstone_exists? + Tombstone.exists?(uri: object_uri) + end + def check_for_spam SpamCheck.perform(@status) end def forward_for_reply - return unless @json['signature'].present? && reply_to_local? + return unless @status.distributable? && @json['signature'].present? && reply_to_local? ActivityPub::RawDistributionWorker.perform_async(Oj.dump(@json), replied_to_status.account_id, [@account.preferred_inbox_url]) end @@ -507,7 +511,7 @@ class ActivityPub::Activity::Create < ActivityPub::Activity end def lock_options - { redis: Redis.current, key: "create:#{@object['id']}" } + { redis: Redis.current, key: "create:#{object_uri}" } end def poll_lock_options diff --git a/app/lib/activitypub/dereferencer.rb b/app/lib/activitypub/dereferencer.rb new file mode 100644 index 0000000000..bea69608fe --- /dev/null +++ b/app/lib/activitypub/dereferencer.rb @@ -0,0 +1,69 @@ +# frozen_string_literal: true + +class ActivityPub::Dereferencer + include JsonLdHelper + + def initialize(uri, permitted_origin: nil, signature_account: nil) + @uri = uri + @permitted_origin = permitted_origin + @signature_account = signature_account + end + + def object + @object ||= fetch_object! + end + + private + + def bear_cap? + @uri.start_with?('bear:') + end + + def fetch_object! + if bear_cap? + fetch_with_token! + else + fetch_with_signature! + end + end + + def fetch_with_token! + perform_request(bear_cap['u'], headers: { 'Authorization' => "Bearer #{bear_cap['t']}" }) + end + + def fetch_with_signature! + perform_request(@uri) + end + + def bear_cap + @bear_cap ||= Addressable::URI.parse(@uri).query_values + end + + def perform_request(uri, headers: nil) + return if invalid_origin?(uri) + + req = Request.new(:get, uri) + + req.add_headers('Accept' => 'application/activity+json, application/ld+json') + req.add_headers(headers) if headers + req.on_behalf_of(@signature_account) if @signature_account + + req.perform do |res| + if res.code == 200 + json = body_to_json(res.body_with_limit) + json if json.present? && json['id'] == uri + else + raise Mastodon::UnexpectedResponseError, res unless response_successful?(res) || response_error_unsalvageable?(res) + end + end + end + + def invalid_origin?(uri) + return true if unsupported_uri_scheme?(uri) + + needle = Addressable::URI.parse(uri).host + haystack = Addressable::URI.parse(@permitted_origin).host + + !haystack.casecmp(needle).zero? + end +end diff --git a/spec/lib/activitypub/dereferencer_spec.rb b/spec/lib/activitypub/dereferencer_spec.rb new file mode 100644 index 0000000000..ce30513d76 --- /dev/null +++ b/spec/lib/activitypub/dereferencer_spec.rb @@ -0,0 +1,73 @@ +require 'rails_helper' + +RSpec.describe ActivityPub::Dereferencer do + describe '#object' do + let(:object) { { '@context': 'https://www.w3.org/ns/activitystreams', id: 'https://example.com/foo', type: 'Note', content: 'Hoge' } } + let(:permitted_origin) { 'https://example.com' } + let(:signature_account) { nil } + let(:uri) { nil } + + subject { described_class.new(uri, permitted_origin: permitted_origin, signature_account: signature_account).object } + + before do + stub_request(:get, 'https://example.com/foo').to_return(body: Oj.dump(object), headers: { 'Content-Type' => 'application/activity+json' }) + end + + context 'with a URI' do + let(:uri) { 'https://example.com/foo' } + + it 'returns object' do + expect(subject.with_indifferent_access).to eq object.with_indifferent_access + end + + context 'with signature account' do + let(:signature_account) { Fabricate(:account) } + + it 'makes signed request' do + subject + expect(a_request(:get, 'https://example.com/foo').with { |req| req.headers['Signature'].present? }).to have_been_made + end + end + + context 'with different origin' do + let(:uri) { 'https://other-example.com/foo' } + + it 'does not make request' do + subject + expect(a_request(:get, 'https://other-example.com/foo')).to_not have_been_made + end + end + end + + context 'with a bearcap' do + let(:uri) { 'bear:?t=hoge&u=https://example.com/foo' } + + it 'makes request with Authorization header' do + subject + expect(a_request(:get, 'https://example.com/foo').with(headers: { 'Authorization' => 'Bearer hoge' })).to have_been_made + end + + it 'returns object' do + expect(subject.with_indifferent_access).to eq object.with_indifferent_access + end + + context 'with signature account' do + let(:signature_account) { Fabricate(:account) } + + it 'makes signed request' do + subject + expect(a_request(:get, 'https://example.com/foo').with { |req| req.headers['Signature'].present? && req.headers['Authorization'] == 'Bearer hoge' }).to have_been_made + end + end + + context 'with different origin' do + let(:uri) { 'bear:?t=hoge&u=https://other-example.com/foo' } + + it 'does not make request' do + subject + expect(a_request(:get, 'https://other-example.com/foo')).to_not have_been_made + end + end + end + end +end