Add ActivityPub secure mode (#11269)
* Add HTTP signature requirement for served ActivityPub resources * Change `SECURE_MODE` to `AUTHORIZED_FETCH` * Add 'Signature' to 'Vary' header and improve code style * Improve code style by adding `public_fetch_mode?` methodmain
parent
4e1260feaa
commit
5bf67ca913
|
@ -4,6 +4,7 @@ class AccountsController < ApplicationController
|
||||||
PAGE_SIZE = 20
|
PAGE_SIZE = 20
|
||||||
|
|
||||||
include AccountControllerConcern
|
include AccountControllerConcern
|
||||||
|
include SignatureAuthentication
|
||||||
|
|
||||||
before_action :set_cache_headers
|
before_action :set_cache_headers
|
||||||
before_action :set_body_classes
|
before_action :set_body_classes
|
||||||
|
@ -39,8 +40,8 @@ class AccountsController < ApplicationController
|
||||||
end
|
end
|
||||||
|
|
||||||
format.json do
|
format.json do
|
||||||
expires_in 3.minutes, public: true
|
expires_in 3.minutes, public: !(authorized_fetch_mode? && signed_request_account.present?)
|
||||||
render json: @account, content_type: 'application/activity+json', serializer: ActivityPub::ActorSerializer, adapter: ActivityPub::Adapter
|
render json: @account, content_type: 'application/activity+json', serializer: ActivityPub::ActorSerializer, adapter: ActivityPub::Adapter, fields: restrict_fields_to
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
@ -132,4 +133,12 @@ class AccountsController < ApplicationController
|
||||||
filtered_statuses.paginate_by_max_id(PAGE_SIZE, params[:max_id], params[:since_id]).to_a
|
filtered_statuses.paginate_by_max_id(PAGE_SIZE, params[:max_id], params[:since_id]).to_a
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def restrict_fields_to
|
||||||
|
if signed_request_account.present? || public_fetch_mode?
|
||||||
|
# Return all fields
|
||||||
|
else
|
||||||
|
%i(id type preferred_username inbox public_key endpoints)
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -4,12 +4,13 @@ class ActivityPub::CollectionsController < Api::BaseController
|
||||||
include SignatureVerification
|
include SignatureVerification
|
||||||
include AccountOwnedConcern
|
include AccountOwnedConcern
|
||||||
|
|
||||||
|
before_action :require_signature!, if: :authorized_fetch_mode?
|
||||||
before_action :set_size
|
before_action :set_size
|
||||||
before_action :set_statuses
|
before_action :set_statuses
|
||||||
before_action :set_cache_headers
|
before_action :set_cache_headers
|
||||||
|
|
||||||
def show
|
def show
|
||||||
expires_in 3.minutes, public: true
|
expires_in 3.minutes, public: public_fetch_mode?
|
||||||
render json: collection_presenter, content_type: 'application/activity+json', serializer: ActivityPub::CollectionSerializer, adapter: ActivityPub::Adapter, skip_activities: true
|
render json: collection_presenter, content_type: 'application/activity+json', serializer: ActivityPub::CollectionSerializer, adapter: ActivityPub::Adapter, skip_activities: true
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
|
@ -5,23 +5,24 @@ class ActivityPub::InboxesController < Api::BaseController
|
||||||
include JsonLdHelper
|
include JsonLdHelper
|
||||||
include AccountOwnedConcern
|
include AccountOwnedConcern
|
||||||
|
|
||||||
|
before_action :skip_unknown_actor_delete
|
||||||
|
before_action :require_signature!
|
||||||
|
|
||||||
def create
|
def create
|
||||||
if unknown_deleted_account?
|
upgrade_account
|
||||||
head 202
|
process_payload
|
||||||
elsif signed_request_account
|
head 202
|
||||||
upgrade_account
|
|
||||||
process_payload
|
|
||||||
head 202
|
|
||||||
else
|
|
||||||
render plain: signature_verification_failure_reason, status: 401
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
|
def skip_unknown_actor_delete
|
||||||
|
head 202 if unknown_deleted_account?
|
||||||
|
end
|
||||||
|
|
||||||
def unknown_deleted_account?
|
def unknown_deleted_account?
|
||||||
json = Oj.load(body, mode: :strict)
|
json = Oj.load(body, mode: :strict)
|
||||||
json['type'] == 'Delete' && json['actor'].present? && json['actor'] == value_or_id(json['object']) && !Account.where(uri: json['actor']).exists?
|
json.is_a?(Hash) && json['type'] == 'Delete' && json['actor'].present? && json['actor'] == value_or_id(json['object']) && !Account.where(uri: json['actor']).exists?
|
||||||
rescue Oj::ParseError
|
rescue Oj::ParseError
|
||||||
false
|
false
|
||||||
end
|
end
|
||||||
|
@ -32,8 +33,12 @@ class ActivityPub::InboxesController < Api::BaseController
|
||||||
|
|
||||||
def body
|
def body
|
||||||
return @body if defined?(@body)
|
return @body if defined?(@body)
|
||||||
@body = request.body.read.force_encoding('UTF-8')
|
|
||||||
|
@body = request.body.read
|
||||||
|
@body.force_encoding('UTF-8') if @body.present?
|
||||||
|
|
||||||
request.body.rewind if request.body.respond_to?(:rewind)
|
request.body.rewind if request.body.respond_to?(:rewind)
|
||||||
|
|
||||||
@body
|
@body
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
|
@ -6,12 +6,12 @@ class ActivityPub::OutboxesController < Api::BaseController
|
||||||
include SignatureVerification
|
include SignatureVerification
|
||||||
include AccountOwnedConcern
|
include AccountOwnedConcern
|
||||||
|
|
||||||
|
before_action :require_signature!, if: :authorized_fetch_mode?
|
||||||
before_action :set_statuses
|
before_action :set_statuses
|
||||||
before_action :set_cache_headers
|
before_action :set_cache_headers
|
||||||
|
|
||||||
def show
|
def show
|
||||||
expires_in 1.minute, public: true unless page_requested?
|
expires_in(page_requested? ? 0 : 3.minutes, public: public_fetch_mode?)
|
||||||
|
|
||||||
render json: outbox_presenter, serializer: ActivityPub::OutboxSerializer, adapter: ActivityPub::Adapter, content_type: 'application/activity+json'
|
render json: outbox_presenter, serializer: ActivityPub::OutboxSerializer, adapter: ActivityPub::Adapter, content_type: 'application/activity+json'
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
|
@ -7,11 +7,13 @@ class ActivityPub::RepliesController < Api::BaseController
|
||||||
|
|
||||||
DESCENDANTS_LIMIT = 60
|
DESCENDANTS_LIMIT = 60
|
||||||
|
|
||||||
|
before_action :require_signature!, if: :authorized_fetch_mode?
|
||||||
before_action :set_status
|
before_action :set_status
|
||||||
before_action :set_cache_headers
|
before_action :set_cache_headers
|
||||||
before_action :set_replies
|
before_action :set_replies
|
||||||
|
|
||||||
def index
|
def index
|
||||||
|
expires_in 0, public: public_fetch_mode?
|
||||||
render json: replies_collection_presenter, serializer: ActivityPub::CollectionSerializer, adapter: ActivityPub::Adapter, content_type: 'application/activity+json', skip_activities: true
|
render json: replies_collection_presenter, serializer: ActivityPub::CollectionSerializer, adapter: ActivityPub::Adapter, content_type: 'application/activity+json', skip_activities: true
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
|
@ -36,6 +36,14 @@ class ApplicationController < ActionController::Base
|
||||||
Rails.env.production?
|
Rails.env.production?
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def authorized_fetch_mode?
|
||||||
|
ENV['AUTHORIZED_FETCH'] == 'true'
|
||||||
|
end
|
||||||
|
|
||||||
|
def public_fetch_mode?
|
||||||
|
!authorized_fetch_mode?
|
||||||
|
end
|
||||||
|
|
||||||
def store_current_location
|
def store_current_location
|
||||||
store_location_for(:user, request.url) unless request.format == :json
|
store_location_for(:user, request.url) unless request.format == :json
|
||||||
end
|
end
|
||||||
|
@ -152,6 +160,6 @@ class ApplicationController < ActionController::Base
|
||||||
end
|
end
|
||||||
|
|
||||||
def set_cache_headers
|
def set_cache_headers
|
||||||
response.headers['Vary'] = 'Accept'
|
response.headers['Vary'] = 'Accept, Signature'
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -11,7 +11,7 @@ module AccountControllerConcern
|
||||||
layout 'public'
|
layout 'public'
|
||||||
|
|
||||||
before_action :set_instance_presenter
|
before_action :set_instance_presenter
|
||||||
before_action :set_link_headers
|
before_action :set_link_headers, if: -> { request.format.nil? || request.format == :html }
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
|
@ -7,12 +7,20 @@ module SignatureVerification
|
||||||
|
|
||||||
include DomainControlHelper
|
include DomainControlHelper
|
||||||
|
|
||||||
|
def require_signature!
|
||||||
|
render plain: signature_verification_failure_reason, status: signature_verification_failure_code unless signed_request_account
|
||||||
|
end
|
||||||
|
|
||||||
def signed_request?
|
def signed_request?
|
||||||
request.headers['Signature'].present?
|
request.headers['Signature'].present?
|
||||||
end
|
end
|
||||||
|
|
||||||
def signature_verification_failure_reason
|
def signature_verification_failure_reason
|
||||||
return @signature_verification_failure_reason if defined?(@signature_verification_failure_reason)
|
@signature_verification_failure_reason
|
||||||
|
end
|
||||||
|
|
||||||
|
def signature_verification_failure_code
|
||||||
|
@signature_verification_failure_code || 401
|
||||||
end
|
end
|
||||||
|
|
||||||
def signed_request_account
|
def signed_request_account
|
||||||
|
@ -125,11 +133,16 @@ module SignatureVerification
|
||||||
end
|
end
|
||||||
|
|
||||||
def account_from_key_id(key_id)
|
def account_from_key_id(key_id)
|
||||||
|
domain = key_id.start_with?('acct:') ? key_id.split('@').last : key_id
|
||||||
|
|
||||||
|
if domain_not_allowed?(domain)
|
||||||
|
@signature_verification_failure_code = 403
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
if key_id.start_with?('acct:')
|
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:/, '')) }
|
||||||
elsif !ActivityPub::TagManager.instance.local_uri?(key_id)
|
elsif !ActivityPub::TagManager.instance.local_uri?(key_id)
|
||||||
return if domain_not_allowed?(key_id)
|
|
||||||
|
|
||||||
account = ActivityPub::TagManager.instance.uri_to_resource(key_id, Account)
|
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) }
|
||||||
account
|
account
|
||||||
|
|
|
@ -2,7 +2,9 @@
|
||||||
|
|
||||||
class FollowerAccountsController < ApplicationController
|
class FollowerAccountsController < ApplicationController
|
||||||
include AccountControllerConcern
|
include AccountControllerConcern
|
||||||
|
include SignatureVerification
|
||||||
|
|
||||||
|
before_action :require_signature!, if: -> { request.format == :json && authorized_fetch_mode? }
|
||||||
before_action :set_cache_headers
|
before_action :set_cache_headers
|
||||||
|
|
||||||
def index
|
def index
|
||||||
|
@ -17,9 +19,9 @@ class FollowerAccountsController < ApplicationController
|
||||||
end
|
end
|
||||||
|
|
||||||
format.json do
|
format.json do
|
||||||
raise Mastodon::NotPermittedError if params[:page].present? && @account.user_hides_network?
|
raise Mastodon::NotPermittedError if page_requested? && @account.user_hides_network?
|
||||||
|
|
||||||
expires_in 3.minutes, public: true if params[:page].blank?
|
expires_in(page_requested? ? 0 : 3.minutes, public: public_fetch_mode?)
|
||||||
|
|
||||||
render json: collection_presenter,
|
render json: collection_presenter,
|
||||||
serializer: ActivityPub::CollectionSerializer,
|
serializer: ActivityPub::CollectionSerializer,
|
||||||
|
@ -35,12 +37,16 @@ class FollowerAccountsController < ApplicationController
|
||||||
@follows ||= Follow.where(target_account: @account).recent.page(params[:page]).per(FOLLOW_PER_PAGE).preload(:account)
|
@follows ||= Follow.where(target_account: @account).recent.page(params[:page]).per(FOLLOW_PER_PAGE).preload(:account)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def page_requested?
|
||||||
|
params[:page].present?
|
||||||
|
end
|
||||||
|
|
||||||
def page_url(page)
|
def page_url(page)
|
||||||
account_followers_url(@account, page: page) unless page.nil?
|
account_followers_url(@account, page: page) unless page.nil?
|
||||||
end
|
end
|
||||||
|
|
||||||
def collection_presenter
|
def collection_presenter
|
||||||
if params[:page].present?
|
if page_requested?
|
||||||
ActivityPub::CollectionPresenter.new(
|
ActivityPub::CollectionPresenter.new(
|
||||||
id: account_followers_url(@account, page: params.fetch(:page, 1)),
|
id: account_followers_url(@account, page: params.fetch(:page, 1)),
|
||||||
type: :ordered,
|
type: :ordered,
|
||||||
|
|
|
@ -2,7 +2,9 @@
|
||||||
|
|
||||||
class FollowingAccountsController < ApplicationController
|
class FollowingAccountsController < ApplicationController
|
||||||
include AccountControllerConcern
|
include AccountControllerConcern
|
||||||
|
include SignatureVerification
|
||||||
|
|
||||||
|
before_action :require_signature!, if: -> { request.format == :json && authorized_fetch_mode? }
|
||||||
before_action :set_cache_headers
|
before_action :set_cache_headers
|
||||||
|
|
||||||
def index
|
def index
|
||||||
|
@ -17,9 +19,9 @@ class FollowingAccountsController < ApplicationController
|
||||||
end
|
end
|
||||||
|
|
||||||
format.json do
|
format.json do
|
||||||
raise Mastodon::NotPermittedError if params[:page].present? && @account.user_hides_network?
|
raise Mastodon::NotPermittedError if page_requested? && @account.user_hides_network?
|
||||||
|
|
||||||
expires_in 3.minutes, public: true if params[:page].blank?
|
expires_in(page_requested? ? 0 : 3.minutes, public: public_fetch_mode?)
|
||||||
|
|
||||||
render json: collection_presenter,
|
render json: collection_presenter,
|
||||||
serializer: ActivityPub::CollectionSerializer,
|
serializer: ActivityPub::CollectionSerializer,
|
||||||
|
@ -35,12 +37,16 @@ class FollowingAccountsController < ApplicationController
|
||||||
@follows ||= Follow.where(account: @account).recent.page(params[:page]).per(FOLLOW_PER_PAGE).preload(:target_account)
|
@follows ||= Follow.where(account: @account).recent.page(params[:page]).per(FOLLOW_PER_PAGE).preload(:target_account)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def page_requested?
|
||||||
|
params[:page].present?
|
||||||
|
end
|
||||||
|
|
||||||
def page_url(page)
|
def page_url(page)
|
||||||
account_following_index_url(@account, page: page) unless page.nil?
|
account_following_index_url(@account, page: page) unless page.nil?
|
||||||
end
|
end
|
||||||
|
|
||||||
def collection_presenter
|
def collection_presenter
|
||||||
if params[:page].present?
|
if page_requested?
|
||||||
ActivityPub::CollectionPresenter.new(
|
ActivityPub::CollectionPresenter.new(
|
||||||
id: account_following_index_url(@account, page: params.fetch(:page, 1)),
|
id: account_following_index_url(@account, page: params.fetch(:page, 1)),
|
||||||
type: :ordered,
|
type: :ordered,
|
||||||
|
|
|
@ -8,11 +8,12 @@ class StatusesController < ApplicationController
|
||||||
|
|
||||||
layout 'public'
|
layout 'public'
|
||||||
|
|
||||||
|
before_action :require_signature!, only: :show, if: -> { request.format == :json && authorized_fetch_mode? }
|
||||||
before_action :set_status
|
before_action :set_status
|
||||||
before_action :set_instance_presenter
|
before_action :set_instance_presenter
|
||||||
before_action :set_link_headers
|
before_action :set_link_headers
|
||||||
before_action :redirect_to_original, only: [:show]
|
before_action :redirect_to_original, only: :show
|
||||||
before_action :set_referrer_policy_header, only: [:show]
|
before_action :set_referrer_policy_header, only: :show
|
||||||
before_action :set_cache_headers
|
before_action :set_cache_headers
|
||||||
before_action :set_body_classes
|
before_action :set_body_classes
|
||||||
before_action :set_autoplay, only: :embed
|
before_action :set_autoplay, only: :embed
|
||||||
|
@ -30,14 +31,14 @@ class StatusesController < ApplicationController
|
||||||
end
|
end
|
||||||
|
|
||||||
format.json do
|
format.json do
|
||||||
expires_in 3.minutes, public: @status.distributable?
|
expires_in 3.minutes, public: @status.distributable? && public_fetch_mode?
|
||||||
render json: @status, content_type: 'application/activity+json', serializer: ActivityPub::NoteSerializer, adapter: ActivityPub::Adapter
|
render json: @status, content_type: 'application/activity+json', serializer: ActivityPub::NoteSerializer, adapter: ActivityPub::Adapter
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def activity
|
def activity
|
||||||
expires_in 3.minutes, public: @status.distributable?
|
expires_in 3.minutes, public: @status.distributable? && public_fetch_mode?
|
||||||
render json: @status, content_type: 'application/activity+json', serializer: ActivityPub::ActivitySerializer, adapter: ActivityPub::Adapter
|
render json: @status, content_type: 'application/activity+json', serializer: ActivityPub::ActivitySerializer, adapter: ActivityPub::Adapter
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
|
@ -1,10 +1,13 @@
|
||||||
# frozen_string_literal: true
|
# frozen_string_literal: true
|
||||||
|
|
||||||
class TagsController < ApplicationController
|
class TagsController < ApplicationController
|
||||||
|
include SignatureVerification
|
||||||
|
|
||||||
PAGE_SIZE = 20
|
PAGE_SIZE = 20
|
||||||
|
|
||||||
layout 'public'
|
layout 'public'
|
||||||
|
|
||||||
|
before_action :require_signature!, if: -> { request.format == :json && authorized_fetch_mode? }
|
||||||
before_action :set_tag
|
before_action :set_tag
|
||||||
before_action :set_body_classes
|
before_action :set_body_classes
|
||||||
before_action :set_instance_presenter
|
before_action :set_instance_presenter
|
||||||
|
@ -30,7 +33,7 @@ class TagsController < ApplicationController
|
||||||
end
|
end
|
||||||
|
|
||||||
format.json do
|
format.json do
|
||||||
expires_in 3.minutes, public: true
|
expires_in 3.minutes, public: public_fetch_mode?
|
||||||
|
|
||||||
@statuses = HashtagQueryService.new.call(@tag, params.slice(:any, :all, :none), current_account, params[:local]).paginate_by_max_id(PAGE_SIZE, params[:max_id])
|
@statuses = HashtagQueryService.new.call(@tag, params.slice(:any, :all, :none), current_account, params[:local]).paginate_by_max_id(PAGE_SIZE, params[:max_id])
|
||||||
@statuses = cache_collection(@statuses, Status)
|
@statuses = cache_collection(@statuses, Status)
|
||||||
|
|
|
@ -33,6 +33,7 @@ class ActivityPub::Adapter < ActiveModelSerializers::Adapter::Base
|
||||||
def serializable_hash(options = nil)
|
def serializable_hash(options = nil)
|
||||||
options = serialization_options(options)
|
options = serialization_options(options)
|
||||||
serialized_hash = serializer.serializable_hash(options)
|
serialized_hash = serializer.serializable_hash(options)
|
||||||
|
serialized_hash = serialized_hash.select { |k, _| options[:fields].include?(k) } if options[:fields]
|
||||||
serialized_hash = self.class.transform_key_casing!(serialized_hash, instance_options)
|
serialized_hash = self.class.transform_key_casing!(serialized_hash, instance_options)
|
||||||
|
|
||||||
{ '@context' => serialized_context }.merge(serialized_hash)
|
{ '@context' => serialized_context }.merge(serialized_hash)
|
||||||
|
|
|
@ -4,7 +4,7 @@ require 'rails_helper'
|
||||||
|
|
||||||
RSpec.describe ActivityPub::InboxesController, type: :controller do
|
RSpec.describe ActivityPub::InboxesController, type: :controller do
|
||||||
describe 'POST #create' do
|
describe 'POST #create' do
|
||||||
context 'if signed_request_account' do
|
context 'with signed_request_account' do
|
||||||
it 'returns 202' do
|
it 'returns 202' do
|
||||||
allow(controller).to receive(:signed_request_account) do
|
allow(controller).to receive(:signed_request_account) do
|
||||||
Fabricate(:account)
|
Fabricate(:account)
|
||||||
|
@ -15,7 +15,7 @@ RSpec.describe ActivityPub::InboxesController, type: :controller do
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
context 'not signed_request_account' do
|
context 'without signed_request_account' do
|
||||||
it 'returns 401' do
|
it 'returns 401' do
|
||||||
allow(controller).to receive(:signed_request_account) do
|
allow(controller).to receive(:signed_request_account) do
|
||||||
false
|
false
|
||||||
|
|
Loading…
Reference in New Issue