* 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?` methodmaster^2
@@ -4,6 +4,7 @@ class AccountsController < ApplicationController | |||
PAGE_SIZE = 20 | |||
include AccountControllerConcern | |||
include SignatureAuthentication | |||
before_action :set_cache_headers | |||
before_action :set_body_classes | |||
@@ -39,8 +40,8 @@ class AccountsController < ApplicationController | |||
end | |||
format.json do | |||
expires_in 3.minutes, public: true | |||
render json: @account, content_type: 'application/activity+json', serializer: ActivityPub::ActorSerializer, adapter: ActivityPub::Adapter | |||
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, fields: restrict_fields_to | |||
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 | |||
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 |
@@ -4,12 +4,13 @@ class ActivityPub::CollectionsController < Api::BaseController | |||
include SignatureVerification | |||
include AccountOwnedConcern | |||
before_action :require_signature!, if: :authorized_fetch_mode? | |||
before_action :set_size | |||
before_action :set_statuses | |||
before_action :set_cache_headers | |||
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 | |||
end | |||
@@ -5,23 +5,24 @@ class ActivityPub::InboxesController < Api::BaseController | |||
include JsonLdHelper | |||
include AccountOwnedConcern | |||
before_action :skip_unknown_actor_delete | |||
before_action :require_signature! | |||
def create | |||
if unknown_deleted_account? | |||
head 202 | |||
elsif signed_request_account | |||
upgrade_account | |||
process_payload | |||
head 202 | |||
else | |||
render plain: signature_verification_failure_reason, status: 401 | |||
end | |||
upgrade_account | |||
process_payload | |||
head 202 | |||
end | |||
private | |||
def skip_unknown_actor_delete | |||
head 202 if unknown_deleted_account? | |||
end | |||
def unknown_deleted_account? | |||
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 | |||
false | |||
end | |||
@@ -32,8 +33,12 @@ class ActivityPub::InboxesController < Api::BaseController | |||
def 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) | |||
@body | |||
end | |||
@@ -6,12 +6,12 @@ class ActivityPub::OutboxesController < Api::BaseController | |||
include SignatureVerification | |||
include AccountOwnedConcern | |||
before_action :require_signature!, if: :authorized_fetch_mode? | |||
before_action :set_statuses | |||
before_action :set_cache_headers | |||
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' | |||
end | |||
@@ -7,11 +7,13 @@ class ActivityPub::RepliesController < Api::BaseController | |||
DESCENDANTS_LIMIT = 60 | |||
before_action :require_signature!, if: :authorized_fetch_mode? | |||
before_action :set_status | |||
before_action :set_cache_headers | |||
before_action :set_replies | |||
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 | |||
end | |||
@@ -36,6 +36,14 @@ class ApplicationController < ActionController::Base | |||
Rails.env.production? | |||
end | |||
def authorized_fetch_mode? | |||
ENV['AUTHORIZED_FETCH'] == 'true' | |||
end | |||
def public_fetch_mode? | |||
!authorized_fetch_mode? | |||
end | |||
def store_current_location | |||
store_location_for(:user, request.url) unless request.format == :json | |||
end | |||
@@ -152,6 +160,6 @@ class ApplicationController < ActionController::Base | |||
end | |||
def set_cache_headers | |||
response.headers['Vary'] = 'Accept' | |||
response.headers['Vary'] = 'Accept, Signature' | |||
end | |||
end |
@@ -11,7 +11,7 @@ module AccountControllerConcern | |||
layout 'public' | |||
before_action :set_instance_presenter | |||
before_action :set_link_headers | |||
before_action :set_link_headers, if: -> { request.format.nil? || request.format == :html } | |||
end | |||
private | |||
@@ -7,12 +7,20 @@ module SignatureVerification | |||
include DomainControlHelper | |||
def require_signature! | |||
render plain: signature_verification_failure_reason, status: signature_verification_failure_code unless signed_request_account | |||
end | |||
def signed_request? | |||
request.headers['Signature'].present? | |||
end | |||
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 | |||
def signed_request_account | |||
@@ -125,11 +133,16 @@ module SignatureVerification | |||
end | |||
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:') | |||
stoplight_wrap_request { ResolveAccountService.new.call(key_id.gsub(/\Aacct:/, '')) } | |||
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 ||= stoplight_wrap_request { ActivityPub::FetchRemoteKeyService.new.call(key_id, id: false) } | |||
account | |||
@@ -2,7 +2,9 @@ | |||
class FollowerAccountsController < ApplicationController | |||
include AccountControllerConcern | |||
include SignatureVerification | |||
before_action :require_signature!, if: -> { request.format == :json && authorized_fetch_mode? } | |||
before_action :set_cache_headers | |||
def index | |||
@@ -17,9 +19,9 @@ class FollowerAccountsController < ApplicationController | |||
end | |||
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, | |||
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) | |||
end | |||
def page_requested? | |||
params[:page].present? | |||
end | |||
def page_url(page) | |||
account_followers_url(@account, page: page) unless page.nil? | |||
end | |||
def collection_presenter | |||
if params[:page].present? | |||
if page_requested? | |||
ActivityPub::CollectionPresenter.new( | |||
id: account_followers_url(@account, page: params.fetch(:page, 1)), | |||
type: :ordered, | |||
@@ -2,7 +2,9 @@ | |||
class FollowingAccountsController < ApplicationController | |||
include AccountControllerConcern | |||
include SignatureVerification | |||
before_action :require_signature!, if: -> { request.format == :json && authorized_fetch_mode? } | |||
before_action :set_cache_headers | |||
def index | |||
@@ -17,9 +19,9 @@ class FollowingAccountsController < ApplicationController | |||
end | |||
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, | |||
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) | |||
end | |||
def page_requested? | |||
params[:page].present? | |||
end | |||
def page_url(page) | |||
account_following_index_url(@account, page: page) unless page.nil? | |||
end | |||
def collection_presenter | |||
if params[:page].present? | |||
if page_requested? | |||
ActivityPub::CollectionPresenter.new( | |||
id: account_following_index_url(@account, page: params.fetch(:page, 1)), | |||
type: :ordered, | |||
@@ -8,11 +8,12 @@ class StatusesController < ApplicationController | |||
layout 'public' | |||
before_action :require_signature!, only: :show, if: -> { request.format == :json && authorized_fetch_mode? } | |||
before_action :set_status | |||
before_action :set_instance_presenter | |||
before_action :set_link_headers | |||
before_action :redirect_to_original, only: [:show] | |||
before_action :set_referrer_policy_header, only: [:show] | |||
before_action :redirect_to_original, only: :show | |||
before_action :set_referrer_policy_header, only: :show | |||
before_action :set_cache_headers | |||
before_action :set_body_classes | |||
before_action :set_autoplay, only: :embed | |||
@@ -30,14 +31,14 @@ class StatusesController < ApplicationController | |||
end | |||
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 | |||
end | |||
end | |||
end | |||
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 | |||
end | |||
@@ -1,10 +1,13 @@ | |||
# frozen_string_literal: true | |||
class TagsController < ApplicationController | |||
include SignatureVerification | |||
PAGE_SIZE = 20 | |||
layout 'public' | |||
before_action :require_signature!, if: -> { request.format == :json && authorized_fetch_mode? } | |||
before_action :set_tag | |||
before_action :set_body_classes | |||
before_action :set_instance_presenter | |||
@@ -30,7 +33,7 @@ class TagsController < ApplicationController | |||
end | |||
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 = cache_collection(@statuses, Status) | |||
@@ -33,6 +33,7 @@ class ActivityPub::Adapter < ActiveModelSerializers::Adapter::Base | |||
def serializable_hash(options = nil) | |||
options = serialization_options(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) | |||
{ '@context' => serialized_context }.merge(serialized_hash) | |||
@@ -4,7 +4,7 @@ require 'rails_helper' | |||
RSpec.describe ActivityPub::InboxesController, type: :controller do | |||
describe 'POST #create' do | |||
context 'if signed_request_account' do | |||
context 'with signed_request_account' do | |||
it 'returns 202' do | |||
allow(controller).to receive(:signed_request_account) do | |||
Fabricate(:account) | |||
@@ -15,7 +15,7 @@ RSpec.describe ActivityPub::InboxesController, type: :controller do | |||
end | |||
end | |||
context 'not signed_request_account' do | |||
context 'without signed_request_account' do | |||
it 'returns 401' do | |||
allow(controller).to receive(:signed_request_account) do | |||
false | |||