Merge pull request 'v4.2.4' (#54) from v4.2.4-branch into asonix/changes

Reviewed-on: #54
This commit is contained in:
asonix 2024-01-29 19:41:28 +00:00
commit 67fcd0e866
62 changed files with 812 additions and 477 deletions

6
.bundler-audit.yml Normal file
View file

@ -0,0 +1,6 @@
---
ignore:
# devise-two-factor advisory about brute-forcing TOTP
# We have rate-limits on authentication endpoints in place (including second
# factor verification) since Mastodon v3.2.0
- CVE-2024-0227

View file

@ -1 +1 @@
3.2.2
3.2.3

View file

@ -2,6 +2,32 @@
All notable changes to this project will be documented in this file.
## [4.2.4] - 2024-01-24
### Fixed
- Fix error when processing remote files with unusually long names ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/28823))
- Fix processing of compacted single-item JSON-LD collections ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/28816))
- Retry 401 errors on replies fetching ([ShadowJonathan](https://github.com/mastodon/mastodon/pull/28788))
- Fix `RecordNotUnique` errors in LinkCrawlWorker ([tribela](https://github.com/mastodon/mastodon/pull/28748))
- Fix Mastodon not correctly processing HTTP Signatures with query strings ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/28443), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/28476))
- Fix potential redirection loop of streaming endpoint ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/28665))
- Fix streaming API redirection ignoring the port of `streaming_api_base_url` ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/28558))
- Fix error when processing link preview with an array as `inLanguage` ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/28252))
- Fix unsupported time zone or locale preventing sign-up ([Gargron](https://github.com/mastodon/mastodon/pull/28035))
- Fix "Hide these posts from home" list setting not refreshing when switching lists ([brianholley](https://github.com/mastodon/mastodon/pull/27763))
- Fix missing background behind dismissable banner in web UI ([Gargron](https://github.com/mastodon/mastodon/pull/27479))
- Fix line wrapping of language selection button with long locale codes ([gunchleoc](https://github.com/mastodon/mastodon/pull/27100), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/27127))
- Fix `Undo Announce` activity not being sent to non-follower authors ([MitarashiDango](https://github.com/mastodon/mastodon/pull/18482))
- Fix N+1s because of association preloaders not actually getting called ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/28339))
- Fix empty column explainer getting cropped under certain conditions ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/28337))
- Fix `LinkCrawlWorker` error when encountering empty OEmbed response ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/28268))
- Fix call to inefficient `delete_matched` cache method in domain blocks ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/28367))
### Security
- Add rate-limit of TOTP authentication attempts at controller level ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/28801))
## [4.2.3] - 2023-12-05
### Fixed

View file

@ -2,7 +2,7 @@
# This needs to be bookworm-slim because the Ruby image is built on bookworm-slim
ARG NODE_VERSION="20.6-bookworm-slim"
FROM ghcr.io/moritzheiber/ruby-jemalloc:3.2.2-slim as ruby
FROM ghcr.io/moritzheiber/ruby-jemalloc:3.2.3-slim as ruby
FROM node:${NODE_VERSION} as build
COPY --link --from=ruby /opt/ruby /opt/ruby

View file

@ -479,7 +479,7 @@ GEM
net-smtp (0.3.3)
net-protocol
net-ssh (7.1.0)
nio4r (2.5.9)
nio4r (2.7.0)
nokogiri (1.15.4)
mini_portile2 (~> 2.8.2)
racc (~> 1.4)
@ -534,7 +534,7 @@ GEM
premailer (~> 1.7, >= 1.7.9)
private_address_check (0.5.0)
public_suffix (5.0.3)
puma (6.3.1)
puma (6.4.2)
nio4r (~> 2.0)
pundit (2.3.0)
activesupport (>= 3.0.0)

View file

@ -25,6 +25,6 @@ class Api::V1::Accounts::NotesController < Api::BaseController
end
def relationships_presenter
AccountRelationshipsPresenter.new([@account.id], current_user.account_id)
AccountRelationshipsPresenter.new([@account], current_user.account_id)
end
end

View file

@ -25,6 +25,6 @@ class Api::V1::Accounts::PinsController < Api::BaseController
end
def relationships_presenter
AccountRelationshipsPresenter.new([@account.id], current_user.account_id)
AccountRelationshipsPresenter.new([@account], current_user.account_id)
end
end

View file

@ -5,11 +5,10 @@ class Api::V1::Accounts::RelationshipsController < Api::BaseController
before_action :require_user!
def index
accounts = Account.without_suspended.where(id: account_ids).select('id')
@accounts = Account.without_suspended.where(id: account_ids).select(:id, :domain).to_a
# .where doesn't guarantee that our results are in the same order
# we requested them, so return the "right" order to the requestor.
@accounts = accounts.index_by(&:id).values_at(*account_ids).compact
render json: @accounts, each_serializer: REST::RelationshipSerializer, relationships: relationships
render json: @accounts.index_by(&:id).values_at(*account_ids).compact, each_serializer: REST::RelationshipSerializer, relationships: relationships
end
private

View file

@ -86,7 +86,7 @@ class Api::V1::AccountsController < Api::BaseController
end
def relationships(**options)
AccountRelationshipsPresenter.new([@account.id], current_user.account_id, **options)
AccountRelationshipsPresenter.new([@account], current_user.account_id, **options)
end
def account_params

View file

@ -25,11 +25,11 @@ class Api::V1::FollowRequestsController < Api::BaseController
private
def account
Account.find(params[:id])
@account ||= Account.find(params[:id])
end
def relationships(**options)
AccountRelationshipsPresenter.new([params[:id]], current_user.account_id, **options)
AccountRelationshipsPresenter.new([account], current_user.account_id, **options)
end
def load_accounts

View file

@ -2,7 +2,7 @@
class Api::V1::StreamingController < Api::BaseController
def index
if Rails.configuration.x.streaming_api_base_url == request.host
if same_host?
not_found
else
redirect_to streaming_api_url, status: 301, allow_other_host: true
@ -11,9 +11,16 @@ class Api::V1::StreamingController < Api::BaseController
private
def same_host?
base_url = Addressable::URI.parse(Rails.configuration.x.streaming_api_base_url)
request.host == base_url.host && request.port == (base_url.port || 80)
end
def streaming_api_url
Addressable::URI.parse(request.url).tap do |uri|
uri.host = Addressable::URI.parse(Rails.configuration.x.streaming_api_base_url).host
base_url = Addressable::URI.parse(Rails.configuration.x.streaming_api_base_url)
uri.host = base_url.host
uri.port = base_url.port
end.to_s
end
end

View file

@ -1,6 +1,10 @@
# frozen_string_literal: true
class Auth::SessionsController < Devise::SessionsController
include Redisable
MAX_2FA_ATTEMPTS_PER_HOUR = 10
layout 'auth'
skip_before_action :require_no_authentication, only: [:create]
@ -134,9 +138,23 @@ class Auth::SessionsController < Devise::SessionsController
session.delete(:attempt_user_updated_at)
end
def clear_2fa_attempt_from_user(user)
redis.del(second_factor_attempts_key(user))
end
def check_second_factor_rate_limits(user)
attempts, = redis.multi do |multi|
multi.incr(second_factor_attempts_key(user))
multi.expire(second_factor_attempts_key(user), 1.hour)
end
attempts >= MAX_2FA_ATTEMPTS_PER_HOUR
end
def on_authentication_success(user, security_measure)
@on_authentication_success_called = true
clear_2fa_attempt_from_user(user)
clear_attempt_from_session
user.update_sign_in!(new_sign_in: true)
@ -168,4 +186,8 @@ class Auth::SessionsController < Devise::SessionsController
user_agent: request.user_agent
)
end
def second_factor_attempts_key(user)
"2fa_auth_attempts:#{user.id}:#{Time.now.utc.hour}"
end
end

View file

@ -91,14 +91,23 @@ module SignatureVerification
raise SignatureVerificationError, "Public key not found for key #{signature_params['keyId']}" if actor.nil?
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?
# 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) }
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?
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
end
def build_signed_string
def build_signed_string(include_query_string: true)
signed_headers.map do |signed_header|
case signed_header
when Request::REQUEST_TARGET
"#{Request::REQUEST_TARGET}: #{request.method.downcase} #{request.path}"
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}"
end
when '(created)'
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?

View file

@ -65,6 +65,11 @@ module TwoFactorAuthenticationConcern
end
def authenticate_with_two_factor_via_otp(user)
if check_second_factor_rate_limits(user)
flash.now[:alert] = I18n.t('users.rate_limited')
return prompt_for_two_factor(user)
end
if valid_otp_attempt?(user)
on_authentication_success(user, :otp)
else

View file

@ -33,7 +33,7 @@ class RelationshipsController < ApplicationController
end
def set_relationships
@relationships = AccountRelationshipsPresenter.new(@accounts.pluck(:id), current_user.account_id)
@relationships = AccountRelationshipsPresenter.new(@accounts, current_user.account_id)
end
def form_account_batch_params

View file

@ -155,7 +155,7 @@ module JsonLdHelper
end
end
def fetch_resource(uri, id, on_behalf_of = nil)
def fetch_resource(uri, id, on_behalf_of = nil, request_options: {})
unless id
json = fetch_resource_without_id_validation(uri, on_behalf_of)
@ -164,14 +164,14 @@ module JsonLdHelper
uri = json['id']
end
json = fetch_resource_without_id_validation(uri, on_behalf_of)
json = fetch_resource_without_id_validation(uri, on_behalf_of, request_options: request_options)
json.present? && json['id'] == uri ? json : nil
end
def fetch_resource_without_id_validation(uri, on_behalf_of = nil, raise_on_temporary_error = false)
def fetch_resource_without_id_validation(uri, on_behalf_of = nil, raise_on_temporary_error = false, request_options: {})
on_behalf_of ||= Account.representative
build_request(uri, on_behalf_of).perform do |response|
build_request(uri, on_behalf_of, options: request_options).perform do |response|
raise Mastodon::UnexpectedResponseError, response unless response_successful?(response) || response_error_unsalvageable?(response) || !raise_on_temporary_error
body_to_json(response.body_with_limit) if response.code == 200
@ -204,8 +204,8 @@ module JsonLdHelper
response.code == 501 || ((400...500).cover?(response.code) && ![401, 408, 429].include?(response.code))
end
def build_request(uri, on_behalf_of = nil)
Request.new(:get, uri).tap do |request|
def build_request(uri, on_behalf_of = nil, options: {})
Request.new(:get, uri, **options).tap do |request|
request.on_behalf_of(on_behalf_of) if on_behalf_of
request.add_headers('Accept' => 'application/activity+json, application/ld+json')
end

View file

@ -4,7 +4,7 @@ import { PureComponent } from 'react';
const iconStyle = {
height: null,
lineHeight: '27px',
width: `${18 * 1.28571429}px`,
minWidth: `${18 * 1.28571429}px`,
};
export default class TextIconButton extends PureComponent {

View file

@ -45,24 +45,20 @@ class Statuses extends PureComponent {
const emptyMessage = <FormattedMessage id='empty_column.explore_statuses' defaultMessage='Nothing is trending right now. Check back later!' />;
return (
<>
<DismissableBanner id='explore/statuses'>
<FormattedMessage id='dismissable_banner.explore_statuses' defaultMessage='These are posts from across the social web that are gaining traction today. Newer posts with more boosts and favorites are ranked higher.' />
</DismissableBanner>
<StatusList
trackScroll
timelineId='explore'
statusIds={statusIds}
scrollKey='explore-statuses'
hasMore={hasMore}
isLoading={isLoading}
onLoadMore={this.handleLoadMore}
emptyMessage={emptyMessage}
bindToDocument={!multiColumn}
withCounters
/>
</>
<StatusList
trackScroll
prepend={<DismissableBanner id='explore/statuses'><FormattedMessage id='dismissable_banner.explore_statuses' defaultMessage='These are posts from across the social web that are gaining traction today. Newer posts with more boosts and favorites are ranked higher.' /></DismissableBanner>}
alwaysPrepend
timelineId='explore'
statusIds={statusIds}
scrollKey='explore-statuses'
hasMore={hasMore}
isLoading={isLoading}
onLoadMore={this.handleLoadMore}
emptyMessage={emptyMessage}
bindToDocument={!multiColumn}
withCounters
/>
);
}

View file

@ -201,7 +201,7 @@ class ListTimeline extends PureComponent {
</div>
<div className='setting-toggle'>
<Toggle id={`list-${id}-exclusive`} defaultChecked={isExclusive} onChange={this.onExclusiveToggle} />
<Toggle id={`list-${id}-exclusive`} checked={isExclusive} onChange={this.onExclusiveToggle} />
<label htmlFor={`list-${id}-exclusive`} className='setting-toggle__label'>
<FormattedMessage id='lists.exclusive' defaultMessage='Hide these posts from home' />
</label>

View file

@ -284,6 +284,7 @@
font-size: 11px;
padding: 0 3px;
line-height: 27px;
white-space: nowrap;
&:hover,
&:active,
@ -4493,11 +4494,6 @@ a.status-card {
align-items: center;
justify-content: center;
@supports (display: grid) {
// hack to fix Chrome <57
contain: strict;
}
& > span {
max-width: 500px;
}

View file

@ -37,13 +37,13 @@ class InlineRenderer
private
def preload_associations_for_status
ActiveRecord::Associations::Preloader.new(records: @object, associations: {
ActiveRecord::Associations::Preloader.new(records: [@object], associations: {
active_mentions: :account,
reblog: {
active_mentions: :account,
},
})
}).call
end
def current_user

View file

@ -37,6 +37,7 @@ class LinkDetailsExtractor
def language
lang = json['inLanguage']
lang = lang.first if lang.is_a?(Array)
lang.is_a?(Hash) ? (lang['alternateName'] || lang['name']) : lang
end

View file

@ -77,6 +77,7 @@ class Request
@url = Addressable::URI.parse(url).normalize
@http_client = options.delete(:http_client)
@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(timeout_class: PerOperationWithDeadline, timeout_options: TIMEOUT)
@options = @options.merge(proxy_url) if use_proxy?
@ -146,7 +147,7 @@ class Request
private
def set_common_headers!
@headers[REQUEST_TARGET] = "#{@verb} #{@url.path}"
@headers[REQUEST_TARGET] = request_target
@headers['User-Agent'] = Mastodon::Version.user_agent
@headers['Host'] = @url.host
@headers['Date'] = Time.now.utc.httpdate
@ -157,6 +158,14 @@ class Request
@headers['Digest'] = "SHA-256=#{Digest::SHA256.base64digest(@options[:body])}"
end
def request_target
if @url.query.nil? || !@full_path
"#{@verb} #{@url.path}"
else
"#{@verb} #{@url.path}?#{@url.query}"
end
end
def signature
algorithm = 'rsa-sha256'
signature = Base64.strict_encode64(@keypair.sign(OpenSSL::Digest.new('SHA256'), signed_string))

View file

@ -16,28 +16,28 @@ class StatusReachFinder
private
def reached_account_inboxes
Account.where(id: reached_account_ids).inboxes
end
def reached_account_ids
# When the status is a reblog, there are no interactions with it
# directly, we assume all interactions are with the original one
if @status.reblog?
[]
[reblog_of_account_id]
else
Account.where(id: reached_account_ids).inboxes
end
end
def reached_account_ids
[
replied_to_account_id,
reblog_of_account_id,
mentioned_account_ids,
reblogs_account_ids,
favourites_account_ids,
replies_account_ids,
].tap do |arr|
arr.flatten!
arr.compact!
arr.uniq!
[
replied_to_account_id,
reblog_of_account_id,
mentioned_account_ids,
reblogs_account_ids,
favourites_account_ids,
replies_account_ids,
].tap do |arr|
arr.flatten!
arr.compact!
arr.uniq!
end
end
end

View file

@ -18,16 +18,12 @@ class AccountDomainBlock < ApplicationRecord
belongs_to :account
validates :domain, presence: true, uniqueness: { scope: :account_id }, domain: true
after_commit :remove_blocking_cache
after_commit :remove_relationship_cache
after_commit :invalidate_domain_blocking_cache
private
def remove_blocking_cache
def invalidate_domain_blocking_cache
Rails.cache.delete("exclude_domains_for:#{account_id}")
end
def remove_relationship_cache
Rails.cache.delete_matched("relationship:#{account_id}:*")
Rails.cache.delete(['exclude_domains', account_id, domain])
end
end

View file

@ -78,9 +78,9 @@ class Announcement < ApplicationRecord
else
scope.select("name, custom_emoji_id, count(*) as count, exists(select 1 from announcement_reactions r where r.account_id = #{account.id} and r.announcement_id = announcement_reactions.announcement_id and r.name = announcement_reactions.name) as me")
end
end
end.to_a
ActiveRecord::Associations::Preloader.new(records: records, associations: :custom_emoji)
ActiveRecord::Associations::Preloader.new(records: records, associations: :custom_emoji).call
records
end

View file

@ -60,12 +60,6 @@ module AccountInteractions
end
end
def domain_blocking_map(target_account_ids, account_id)
accounts_map = Account.where(id: target_account_ids).select('id, domain').each_with_object({}) { |a, h| h[a.id] = a.domain }
blocked_domains = domain_blocking_map_by_domain(accounts_map.values.compact, account_id)
accounts_map.reduce({}) { |h, (id, domain)| h.merge(id => blocked_domains[domain]) }
end
def domain_blocking_map_by_domain(target_domains, account_id)
follow_mapping(AccountDomainBlock.where(account_id: account_id, domain: target_domains), :domain)
end

View file

@ -122,7 +122,7 @@ module AccountSearch
tsquery = generate_query_for_search(terms)
find_by_sql([BASIC_SEARCH_SQL, { limit: limit, offset: offset, tsquery: tsquery }]).tap do |records|
ActiveRecord::Associations::Preloader.new(records: records, associations: :account_stat)
ActiveRecord::Associations::Preloader.new(records: records, associations: [:account_stat, { user: :role }]).call
end
end
@ -131,7 +131,7 @@ module AccountSearch
sql_template = following ? ADVANCED_SEARCH_WITH_FOLLOWING : ADVANCED_SEARCH_WITHOUT_FOLLOWING
find_by_sql([sql_template, { id: account.id, limit: limit, offset: offset, tsquery: tsquery }]).tap do |records|
ActiveRecord::Associations::Preloader.new(records: records, associations: :account_stat)
ActiveRecord::Associations::Preloader.new(records: records, associations: [:account_stat, { user: :role }]).call
end
end

View file

@ -10,7 +10,7 @@ module RelationshipCacheable
private
def remove_relationship_cache
Rails.cache.delete("relationship:#{account_id}:#{target_account_id}")
Rails.cache.delete("relationship:#{target_account_id}:#{account_id}")
Rails.cache.delete(['relationship', account_id, target_account_id])
Rails.cache.delete(['relationship', target_account_id, account_id])
end
end

View file

@ -111,7 +111,7 @@ class Notification < ApplicationRecord
# Instead of using the usual `includes`, manually preload each type.
# If polymorphic associations are loaded with the usual `includes`, other types of associations will be loaded more.
ActiveRecord::Associations::Preloader.new(records: grouped_notifications, associations: associations)
ActiveRecord::Associations::Preloader.new(records: grouped_notifications, associations: associations).call
end
unique_target_statuses = notifications.filter_map(&:target_status).uniq

View file

@ -96,11 +96,9 @@ class User < ApplicationRecord
accepts_nested_attributes_for :invite_request, reject_if: ->(attributes) { attributes['text'].blank? && !Setting.require_invite_text }
validates :invite_request, presence: true, on: :create, if: :invite_text_required?
validates :locale, inclusion: I18n.available_locales.map(&:to_s), if: :locale?
validates_with BlacklistedEmailValidator, if: -> { ENV['EMAIL_DOMAIN_LISTS_APPLY_AFTER_CONFIRMATION'] == 'true' || !confirmed? }
validates_with EmailMxValidator, if: :validate_email_dns?
validates :agreement, acceptance: { allow_nil: false, accept: [true, 'true', '1'] }, on: :create
validates :time_zone, inclusion: { in: ActiveSupport::TimeZone.all.map { |tz| tz.tzinfo.name } }, allow_blank: true
# Honeypot/anti-spam fields
attr_accessor :registration_form_time, :website, :confirm_password
@ -124,6 +122,8 @@ class User < ApplicationRecord
before_validation :sanitize_languages
before_validation :sanitize_role
before_validation :sanitize_time_zone
before_validation :sanitize_locale
before_create :set_approved
after_commit :send_pending_devise_notifications
after_create_commit :trigger_webhooks
@ -451,9 +451,15 @@ class User < ApplicationRecord
end
def sanitize_role
return if role.nil?
self.role = nil if role.present? && role.everyone?
end
self.role = nil if role.everyone?
def sanitize_time_zone
self.time_zone = nil if time_zone.present? && ActiveSupport::TimeZone[time_zone].nil?
end
def sanitize_locale
self.locale = nil if locale.present? && I18n.available_locales.exclude?(locale.to_sym)
end
def prepare_new_user!

View file

@ -5,8 +5,9 @@ class AccountRelationshipsPresenter
:muting, :requested, :requested_by, :domain_blocking,
:endorsed, :account_note
def initialize(account_ids, current_account_id, **options)
@account_ids = account_ids.map { |a| a.is_a?(Account) ? a.id : a.to_i }
def initialize(accounts, current_account_id, **options)
@accounts = accounts.to_a
@account_ids = @accounts.pluck(:id)
@current_account_id = current_account_id
@following = cached[:following].merge(Account.following_map(@uncached_account_ids, @current_account_id))
@ -16,10 +17,11 @@ class AccountRelationshipsPresenter
@muting = cached[:muting].merge(Account.muting_map(@uncached_account_ids, @current_account_id))
@requested = cached[:requested].merge(Account.requested_map(@uncached_account_ids, @current_account_id))
@requested_by = cached[:requested_by].merge(Account.requested_by_map(@uncached_account_ids, @current_account_id))
@domain_blocking = cached[:domain_blocking].merge(Account.domain_blocking_map(@uncached_account_ids, @current_account_id))
@endorsed = cached[:endorsed].merge(Account.endorsed_map(@uncached_account_ids, @current_account_id))
@account_note = cached[:account_note].merge(Account.account_note_map(@uncached_account_ids, @current_account_id))
@domain_blocking = domain_blocking_map
cache_uncached!
@following.merge!(options[:following_map] || {})
@ -36,6 +38,31 @@ class AccountRelationshipsPresenter
private
def domain_blocking_map
target_domains = @accounts.pluck(:domain).compact.uniq
blocks_by_domain = {}
# Fetch from cache
cache_keys = target_domains.map { |domain| domain_cache_key(domain) }
Rails.cache.read_multi(*cache_keys).each do |key, blocking|
blocks_by_domain[key.last] = blocking
end
uncached_domains = target_domains - blocks_by_domain.keys
# Read uncached values from database
AccountDomainBlock.where(account_id: @current_account_id, domain: uncached_domains).pluck(:domain).each do |domain|
blocks_by_domain[domain] = true
end
# Write database reads to cache
to_cache = uncached_domains.to_h { |domain| [domain_cache_key(domain), blocks_by_domain[domain]] }
Rails.cache.write_multi(to_cache, expires_in: 1.day)
# Return formatted value
@accounts.each_with_object({}) { |account, h| h[account.id] = blocks_by_domain[account.domain] }
end
def cached
return @cached if defined?(@cached)
@ -47,28 +74,23 @@ class AccountRelationshipsPresenter
muting: {},
requested: {},
requested_by: {},
domain_blocking: {},
endorsed: {},
account_note: {},
}
@uncached_account_ids = []
@uncached_account_ids = @account_ids.uniq
@account_ids.each do |account_id|
maps_for_account = Rails.cache.read("relationship:#{@current_account_id}:#{account_id}")
if maps_for_account.is_a?(Hash)
@cached.deep_merge!(maps_for_account)
else
@uncached_account_ids << account_id
end
cache_ids = @account_ids.map { |account_id| relationship_cache_key(account_id) }
Rails.cache.read_multi(*cache_ids).each do |key, maps_for_account|
@cached.deep_merge!(maps_for_account)
@uncached_account_ids.delete(key.last)
end
@cached
end
def cache_uncached!
@uncached_account_ids.each do |account_id|
to_cache = @uncached_account_ids.to_h do |account_id|
maps_for_account = {
following: { account_id => following[account_id] },
followed_by: { account_id => followed_by[account_id] },
@ -77,12 +99,21 @@ class AccountRelationshipsPresenter
muting: { account_id => muting[account_id] },
requested: { account_id => requested[account_id] },
requested_by: { account_id => requested_by[account_id] },
domain_blocking: { account_id => domain_blocking[account_id] },
endorsed: { account_id => endorsed[account_id] },
account_note: { account_id => account_note[account_id] },
}
Rails.cache.write("relationship:#{@current_account_id}:#{account_id}", maps_for_account, expires_in: 1.day)
[relationship_cache_key(account_id), maps_for_account]
end
Rails.cache.write_multi(to_cache, expires_in: 1.day)
end
def domain_cache_key(domain)
['exclude_domains', @current_account_id, domain]
end
def relationship_cache_key(account_id)
['relationship', @current_account_id, account_id]
end
end

View file

@ -86,8 +86,8 @@ class InitialStateSerializer < ActiveModel::Serializer
ActiveRecord::Associations::Preloader.new(
records: [object.current_account, object.admin, object.owner, object.disabled_account, object.moved_to_account].compact,
associations: [:account_stat, :user, { moved_to_account: [:account_stat, :user] }]
)
associations: [:account_stat, { user: :role, moved_to_account: [:account_stat, { user: :role }] }]
).call
store[object.current_account.id.to_s] = ActiveModelSerializers::SerializableResource.new(object.current_account, serializer: REST::AccountSerializer) if object.current_account
store[object.admin.id.to_s] = ActiveModelSerializers::SerializableResource.new(object.admin, serializer: REST::AccountSerializer) if object.admin

View file

@ -218,7 +218,7 @@ class AccountSearchService < BaseService
records = query_builder.build.limit(limit_for_non_exact_results).offset(offset).objects.compact
ActiveRecord::Associations::Preloader.new(records: records, associations: :account_stat)
ActiveRecord::Associations::Preloader.new(records: records, associations: [:account_stat, { user: :role }]).call
records
rescue Faraday::ConnectionFailed, Parslet::ParseFailed

View file

@ -23,9 +23,9 @@ class ActivityPub::FetchFeaturedCollectionService < BaseService
case collection['type']
when 'Collection', 'CollectionPage'
collection['items']
as_array(collection['items'])
when 'OrderedCollection', 'OrderedCollectionPage'
collection['orderedItems']
as_array(collection['orderedItems'])
end
end

View file

@ -26,9 +26,9 @@ class ActivityPub::FetchRepliesService < BaseService
case collection['type']
when 'Collection', 'CollectionPage'
collection['items']
as_array(collection['items'])
when 'OrderedCollection', 'OrderedCollectionPage'
collection['orderedItems']
as_array(collection['orderedItems'])
end
end
@ -37,7 +37,20 @@ class ActivityPub::FetchRepliesService < BaseService
return unless @allow_synchronous_requests
return if non_matching_uri_hosts?(@account.uri, collection_or_uri)
fetch_resource_without_id_validation(collection_or_uri, nil, true)
# NOTE: For backward compatibility reasons, Mastodon signs outgoing
# queries incorrectly by default.
#
# While this is relevant for all URLs with query strings, this is
# the only code path where this happens in practice.
#
# Therefore, retry with correct signatures if this fails.
begin
fetch_resource_without_id_validation(collection_or_uri, nil, true)
rescue Mastodon::UnexpectedResponseError => e
raise unless e.response && e.response.code == 401 && Addressable::URI.parse(collection_or_uri).query.present?
fetch_resource_without_id_validation(collection_or_uri, nil, true, request_options: { with_query_string: true })
end
end
def filtered_replies

View file

@ -59,9 +59,9 @@ class ActivityPub::SynchronizeFollowersService < BaseService
case collection['type']
when 'Collection', 'CollectionPage'
collection['items']
as_array(collection['items'])
when 'OrderedCollection', 'OrderedCollectionPage'
collection['orderedItems']
as_array(collection['orderedItems'])
end
end

View file

@ -11,7 +11,7 @@ class BatchedRemoveStatusService < BaseService
ActiveRecord::Associations::Preloader.new(
records: statuses,
associations: options[:skip_side_effects] ? :reblogs : [:account, :tags, reblogs: :account]
)
).call
statuses_and_reblogs = statuses.flat_map { |status| [status] + status.reblogs }
@ -23,7 +23,7 @@ class BatchedRemoveStatusService < BaseService
ActiveRecord::Associations::Preloader.new(
records: statuses_with_account_conversations,
associations: [mentions: :account]
)
).call
statuses_with_account_conversations.each(&:unlink_from_conversations!)

View file

@ -100,7 +100,7 @@ class FetchOEmbedService
end
def validate(oembed)
oembed if oembed[:version].to_s == '1.0' && oembed[:type].present?
oembed if oembed.present? && oembed[:version].to_s == '1.0' && oembed[:type].present?
end
def html

View file

@ -69,7 +69,7 @@ class Keys::QueryService < BaseService
return if json['items'].blank?
@devices = json['items'].map do |device|
@devices = as_array(json['items']).map do |device|
Device.new(device_id: device['id'], name: device['name'], identity_key: device.dig('identityKey', 'publicKeyBase64'), fingerprint_key: device.dig('fingerprintKey', 'publicKeyBase64'), claim_url: device['claim'])
end
rescue HTTP::Error, OpenSSL::SSL::SSLError, Mastodon::Error => e

View file

@ -43,11 +43,7 @@ class ReblogService < BaseService
def create_notification(reblog)
reblogged_status = reblog.reblog
if reblogged_status.account.local?
LocalNotificationWorker.perform_async(reblogged_status.account_id, reblog.id, reblog.class.name, 'reblog')
elsif reblogged_status.account.activitypub? && !reblogged_status.account.following?(reblog.account)
ActivityPub::DeliveryWorker.perform_async(build_json(reblog), reblog.account_id, reblogged_status.account.inbox_url)
end
LocalNotificationWorker.perform_async(reblogged_status.account_id, reblog.id, reblog.class.name, 'reblog') if reblogged_status.account.local?
end
def bump_potential_friendship(account, reblog)

View file

@ -7,7 +7,7 @@ class LinkCrawlWorker
def perform(status_id)
FetchLinkCardService.new.call(Status.find(status_id))
rescue ActiveRecord::RecordNotFound
rescue ActiveRecord::RecordNotFound, ActiveRecord::RecordNotUnique
true
end
end

View file

@ -1838,6 +1838,7 @@ en-DG:
go_to_sso_account_settings: Go to your identity provider's account settings
invalid_otp_token: Invalid two-factor code
otp_lost_help_html: If you lost access to both, you may get in touch with %{email}
rate_limited: Too many authentication attempts, try again later.
seamless_external_login: You are logged in via an external service, so password and e-mail settings are not available.
signed_in_as: 'Signed in as:'
verification:

View file

@ -1838,6 +1838,7 @@ en-LN:
go_to_sso_account_settings: Go to your identity provider's account settings
invalid_otp_token: Invalid two-factor code
otp_lost_help_html: If you lost access to both, you may get in touch with %{email}
rate_limited: Too many authentication attempts, try again later.
seamless_external_login: You are logged in via an external service, so password and e-mail settings are not available.
signed_in_as: 'Signed in as:'
verification:

View file

@ -828,6 +828,7 @@ en-SQ:
suspend: "%{name} suspended %{target}'s account"
appeal_approved: Appealed
appeal_pending: Appeal pending
appeal_rejected: Appeal rejected
system_checks:
database_schema_check:
message_html: There are pending database migrations. Please run them to ensure the application behaves as expected
@ -1016,7 +1017,6 @@ en-SQ:
guide_link: https://crowdin.com/project/mastodon
guide_link_text: Everyone can contribute.
sensitive_content: Sensitive content
toot_layout: Toot layout
application_mailer:
notification_preferences: Change e-mail preferences
salutation: "%{name},"
@ -1835,8 +1835,10 @@ en-SQ:
title: Welcome aboard, %{name}!
users:
follow_limit_reached: You cannot follow more than %{limit} people
go_to_sso_account_settings: Go to your identity provider's account settings
invalid_otp_token: Invalid two-factor code
otp_lost_help_html: If you lost access to both, you may get in touch with %{email}
rate_limited: Too many authentication attempts, try again later.
seamless_external_login: You are logged in via an external service, so password and e-mail settings are not available.
signed_in_as: 'Signed in as:'
verification:

View file

@ -1838,6 +1838,7 @@ en:
go_to_sso_account_settings: Go to your identity provider's account settings
invalid_otp_token: Invalid two-factor code
otp_lost_help_html: If you lost access to both, you may get in touch with %{email}
rate_limited: Too many authentication attempts, try again later.
seamless_external_login: You are logged in via an external service, so password and e-mail settings are not available.
signed_in_as: 'Signed in as:'
verification:

View file

@ -56,7 +56,7 @@ services:
web:
build: .
image: ghcr.io/mastodon/mastodon:v4.2.3
image: ghcr.io/mastodon/mastodon:v4.2.4
restart: always
env_file: .env.production
command: bash -c "rm -f /mastodon/tmp/pids/server.pid; bundle exec rails s -p 3000"
@ -77,7 +77,7 @@ services:
streaming:
build: .
image: ghcr.io/mastodon/mastodon:v4.2.3
image: ghcr.io/mastodon/mastodon:v4.2.4
restart: always
env_file: .env.production
command: node ./streaming
@ -95,7 +95,7 @@ services:
sidekiq:
build: .
image: ghcr.io/mastodon/mastodon:v4.2.3
image: ghcr.io/mastodon/mastodon:v4.2.4
restart: always
env_file: .env.production
command: bundle exec sidekiq

View file

@ -13,7 +13,7 @@ module Mastodon
end
def patch
3
4
end
def default_prerelease

View file

@ -16,7 +16,7 @@ module Paperclip
private
def cache_current_values
@original_filename = filename_from_content_disposition.presence || filename_from_path.presence || 'data'
@original_filename = truncated_filename
@tempfile = copy_to_tempfile(@target)
@content_type = ContentTypeDetector.new(@tempfile.path).detect
@size = File.size(@tempfile)
@ -43,6 +43,13 @@ module Paperclip
source.response.connection.close
end
def truncated_filename
filename = filename_from_content_disposition.presence || filename_from_path.presence || 'data'
extension = File.extname(filename)
basename = File.basename(filename, extension)
[basename[...20], extension[..4]].compact_blank.join
end
def filename_from_content_disposition
disposition = @target.response.headers['content-disposition']
disposition&.match(/filename="([^"]*)"/)&.captures&.first

View file

@ -17,7 +17,7 @@ namespace :db do
task :pre_migration_check do
version = ActiveRecord::Base.connection.select_one("SELECT current_setting('server_version_num') AS v")['v'].to_i
abort 'This version of Mastodon requires PostgreSQL 9.5 or newer. Please update PostgreSQL before updating Mastodon' if version < 90_500
abort 'This version of Mastodon requires PostgreSQL 10.0 or newer. Please update PostgreSQL before updating Mastodon' if version < 100_000
end
Rake::Task['db:migrate'].enhance(['db:pre_migration_check'])

View file

@ -5,7 +5,7 @@ require 'rails_helper'
describe Api::V1::StreamingController do
around(:each) do |example|
before = Rails.configuration.x.streaming_api_base_url
Rails.configuration.x.streaming_api_base_url = Rails.configuration.x.web_domain
Rails.configuration.x.streaming_api_base_url = "wss://#{Rails.configuration.x.web_domain}"
example.run
Rails.configuration.x.streaming_api_base_url = before
end

View file

@ -263,6 +263,26 @@ RSpec.describe Auth::SessionsController do
end
end
context 'when repeatedly using an invalid TOTP code before using a valid code' do
before do
stub_const('Auth::SessionsController::MAX_2FA_ATTEMPTS_PER_HOUR', 2)
end
it 'does not log the user in' do
# Travel to the beginning of an hour to avoid crossing rate-limit buckets
travel_to '2023-12-20T10:00:00Z'
Auth::SessionsController::MAX_2FA_ATTEMPTS_PER_HOUR.times do
post :create, params: { user: { otp_attempt: '1234' } }, session: { attempt_user_id: user.id, attempt_user_updated_at: user.updated_at.to_s }
expect(controller.current_user).to be_nil
end
post :create, params: { user: { otp_attempt: user.current_otp } }, session: { attempt_user_id: user.id, attempt_user_updated_at: user.updated_at.to_s }
expect(controller.current_user).to be_nil
expect(flash[:alert]).to match I18n.t('users.rate_limited')
end
end
context 'when using a valid OTP' do
before do
post :create, params: { user: { otp_attempt: user.current_otp } }, session: { attempt_user_id: user.id, attempt_user_updated_at: user.updated_at.to_s }

View file

@ -1,305 +0,0 @@
# frozen_string_literal: true
require 'rails_helper'
describe SignatureVerification do
let(:wrapped_actor_class) do
Class.new do
attr_reader :wrapped_account
def initialize(wrapped_account)
@wrapped_account = wrapped_account
end
delegate :uri, :keypair, to: :wrapped_account
end
end
controller(ApplicationController) do
include SignatureVerification
before_action :require_actor_signature!, only: [:signature_required]
def success
head 200
end
def alternative_success
head 200
end
def signature_required
head 200
end
end
before do
routes.draw do
match :via => [:get, :post], 'success' => 'anonymous#success'
match :via => [:get, :post], 'signature_required' => 'anonymous#signature_required'
end
end
context 'without signature header' do
before do
get :success
end
describe '#signed_request?' do
it 'returns false' do
expect(controller.signed_request?).to be false
end
end
describe '#signed_request_account' do
it 'returns nil' do
expect(controller.signed_request_account).to be_nil
end
end
end
context 'with signature header' do
let!(:author) { Fabricate(:account, domain: 'example.com', uri: 'https://example.com/actor') }
context 'without body' do
before do
get :success
fake_request = Request.new(:get, request.url)
fake_request.on_behalf_of(author)
request.headers.merge!(fake_request.headers)
end
describe '#signed_request?' do
it 'returns true' do
expect(controller.signed_request?).to be true
end
end
describe '#signed_request_account' do
it 'returns an account' do
expect(controller.signed_request_account).to eq author
end
it 'returns nil when path does not match' do
request.path = '/alternative-path'
expect(controller.signed_request_account).to be_nil
end
it 'returns nil when method does not match' do
post :success
expect(controller.signed_request_account).to be_nil
end
end
end
context 'with a valid actor that is not an Account' do
let(:actor) { wrapped_actor_class.new(author) }
before do
get :success
fake_request = Request.new(:get, request.url)
fake_request.on_behalf_of(author)
request.headers.merge!(fake_request.headers)
allow(ActivityPub::TagManager.instance).to receive(:uri_to_actor).with(anything) do
actor
end
end
describe '#signed_request?' do
it 'returns true' do
expect(controller.signed_request?).to be true
end
end
describe '#signed_request_account' do
it 'returns nil' do
expect(controller.signed_request_account).to be_nil
end
end
describe '#signed_request_actor' do
it 'returns the expected actor' do
expect(controller.signed_request_actor).to eq actor
end
end
end
context 'with request with unparsable Date header' do
before do
get :success
fake_request = Request.new(:get, request.url)
fake_request.add_headers({ 'Date' => 'wrong date' })
fake_request.on_behalf_of(author)
request.headers.merge!(fake_request.headers)
end
describe '#signed_request?' do
it 'returns true' do
expect(controller.signed_request?).to be true
end
end
describe '#signed_request_account' do
it 'returns nil' do
expect(controller.signed_request_account).to be_nil
end
end
describe '#signature_verification_failure_reason' do
it 'contains an error description' do
controller.signed_request_account
expect(controller.signature_verification_failure_reason[:error]).to eq 'Invalid Date header: not RFC 2616 compliant date: "wrong date"'
end
end
end
context 'with request older than a day' do
before do
get :success
fake_request = Request.new(:get, request.url)
fake_request.add_headers({ 'Date' => 2.days.ago.utc.httpdate })
fake_request.on_behalf_of(author)
request.headers.merge!(fake_request.headers)
end
describe '#signed_request?' do
it 'returns true' do
expect(controller.signed_request?).to be true
end
end
describe '#signed_request_account' do
it 'returns nil' do
expect(controller.signed_request_account).to be_nil
end
end
describe '#signature_verification_failure_reason' do
it 'contains an error description' do
controller.signed_request_account
expect(controller.signature_verification_failure_reason[:error]).to eq 'Signed request date outside acceptable time window'
end
end
end
context 'with inaccessible key' do
before do
get :success
author = Fabricate(:account, domain: 'localhost:5000', uri: 'http://localhost:5000/actor')
fake_request = Request.new(:get, request.url)
fake_request.on_behalf_of(author)
author.destroy
request.headers.merge!(fake_request.headers)
stub_request(:get, 'http://localhost:5000/actor#main-key').to_raise(Mastodon::HostValidationError)
end
describe '#signed_request?' do
it 'returns true' do
expect(controller.signed_request?).to be true
end
end
describe '#signed_request_account' do
it 'returns nil' do
expect(controller.signed_request_account).to be_nil
end
end
end
context 'with body' do
before do
allow(controller).to receive(:actor_refresh_key!).and_return(author)
post :success, body: 'Hello world'
fake_request = Request.new(:post, request.url, body: 'Hello world')
fake_request.on_behalf_of(author)
request.headers.merge!(fake_request.headers)
end
describe '#signed_request?' do
it 'returns true' do
expect(controller.signed_request?).to be true
end
end
describe '#signed_request_account' do
it 'returns an account' do
expect(controller.signed_request_account).to eq author
end
end
context 'when path does not match' do
before do
request.path = '/alternative-path'
end
describe '#signed_request_account' do
it 'returns nil' do
expect(controller.signed_request_account).to be_nil
end
end
describe '#signature_verification_failure_reason' do
it 'contains an error description' do
controller.signed_request_account
expect(controller.signature_verification_failure_reason[:error]).to include('using rsa-sha256 (RSASSA-PKCS1-v1_5 with SHA-256)')
expect(controller.signature_verification_failure_reason[:signed_string]).to include("(request-target): post /alternative-path\n")
end
end
end
context 'when method does not match' do
before do
get :success
end
describe '#signed_request_account' do
it 'returns nil' do
expect(controller.signed_request_account).to be_nil
end
end
end
context 'when body has been tampered' do
before do
post :success, body: 'doo doo doo'
end
describe '#signed_request_account' do
it 'returns nil when body has been tampered' do
expect(controller.signed_request_account).to be_nil
end
end
end
end
end
context 'when a signature is required' do
before do
get :signature_required
end
context 'without signature header' do
it 'returns HTTP 401' do
expect(response).to have_http_status(401)
end
it 'returns an error' do
expect(Oj.load(response.body)['error']).to eq 'Request not signed'
end
end
end
end

View file

@ -31,11 +31,5 @@ describe Settings::Preferences::AppearanceController do
expect(response).to redirect_to(settings_preferences_appearance_path)
end
it 'renders show on failure' do
put :update, params: { user: { locale: 'fake option' } }
expect(response).to render_template('preferences/appearance/show')
end
end
end

View file

@ -112,6 +112,14 @@ RSpec.describe ActivityPub::TagManager do
expect(subject.cc(status)).to include(subject.uri_for(foo))
expect(subject.cc(status)).to_not include(subject.uri_for(alice))
end
it 'returns poster of reblogged post, if reblog' do
bob = Fabricate(:account, username: 'bob', domain: 'example.com', inbox_url: 'http://example.com/bob')
alice = Fabricate(:account, username: 'alice')
status = Fabricate(:status, visibility: :public, account: bob)
reblog = Fabricate(:status, visibility: :public, account: alice, reblog: status)
expect(subject.cc(reblog)).to include(subject.uri_for(bob))
end
end
describe '#local_uri?' do

View file

@ -27,12 +27,6 @@ RSpec.describe User do
expect(user).to model_have_error_on_field(:account)
end
it 'is invalid without a valid locale' do
user = Fabricate.build(:user, locale: 'toto')
user.valid?
expect(user).to model_have_error_on_field(:locale)
end
it 'is invalid without a valid email' do
user = Fabricate.build(:user, email: 'john@')
user.valid?
@ -45,6 +39,18 @@ RSpec.describe User do
expect(user.valid?).to be true
end
it 'cleans out invalid locale' do
user = Fabricate.build(:user, locale: 'toto')
expect(user.valid?).to be true
expect(user.locale).to be_nil
end
it 'cleans out invalid timezone' do
user = Fabricate.build(:user, time_zone: 'toto')
expect(user.valid?).to be true
expect(user.time_zone).to be_nil
end
it 'cleans out empty string from languages' do
user = Fabricate.build(:user, chosen_languages: [''])
user.valid?

View file

@ -5,30 +5,57 @@ require 'rails_helper'
RSpec.describe AccountRelationshipsPresenter do
describe '.initialize' do
before do
allow(Account).to receive(:following_map).with(account_ids, current_account_id).and_return(default_map)
allow(Account).to receive(:followed_by_map).with(account_ids, current_account_id).and_return(default_map)
allow(Account).to receive(:blocking_map).with(account_ids, current_account_id).and_return(default_map)
allow(Account).to receive(:muting_map).with(account_ids, current_account_id).and_return(default_map)
allow(Account).to receive(:requested_map).with(account_ids, current_account_id).and_return(default_map)
allow(Account).to receive(:requested_by_map).with(account_ids, current_account_id).and_return(default_map)
allow(Account).to receive(:domain_blocking_map).with(account_ids, current_account_id).and_return(default_map)
allow(Account).to receive(:following_map).with(accounts.pluck(:id), current_account_id).and_return(default_map)
allow(Account).to receive(:followed_by_map).with(accounts.pluck(:id), current_account_id).and_return(default_map)
allow(Account).to receive(:blocking_map).with(accounts.pluck(:id), current_account_id).and_return(default_map)
allow(Account).to receive(:muting_map).with(accounts.pluck(:id), current_account_id).and_return(default_map)
allow(Account).to receive(:requested_map).with(accounts.pluck(:id), current_account_id).and_return(default_map)
allow(Account).to receive(:requested_by_map).with(accounts.pluck(:id), current_account_id).and_return(default_map)
end
let(:presenter) { described_class.new(account_ids, current_account_id, **options) }
let(:presenter) { described_class.new(accounts, current_account_id, **options) }
let(:current_account_id) { Fabricate(:account).id }
let(:account_ids) { [Fabricate(:account).id] }
let(:default_map) { { 1 => true } }
let(:accounts) { [Fabricate(:account)] }
let(:default_map) { { accounts[0].id => true } }
context 'when options are not set' do
let(:options) { {} }
it 'sets default maps' do
expect(presenter.following).to eq default_map
expect(presenter.followed_by).to eq default_map
expect(presenter.blocking).to eq default_map
expect(presenter.muting).to eq default_map
expect(presenter.requested).to eq default_map
expect(presenter.domain_blocking).to eq default_map
expect(presenter).to have_attributes(
following: default_map,
followed_by: default_map,
blocking: default_map,
muting: default_map,
requested: default_map,
domain_blocking: { accounts[0].id => nil }
)
end
end
context 'with a warm cache' do
let(:options) { {} }
before do
described_class.new(accounts, current_account_id, **options)
allow(Account).to receive(:following_map).with([], current_account_id).and_return({})
allow(Account).to receive(:followed_by_map).with([], current_account_id).and_return({})
allow(Account).to receive(:blocking_map).with([], current_account_id).and_return({})
allow(Account).to receive(:muting_map).with([], current_account_id).and_return({})
allow(Account).to receive(:requested_map).with([], current_account_id).and_return({})
allow(Account).to receive(:requested_by_map).with([], current_account_id).and_return({})
end
it 'sets returns expected values' do
expect(presenter).to have_attributes(
following: default_map,
followed_by: default_map,
blocking: default_map,
muting: default_map,
requested: default_map,
domain_blocking: { accounts[0].id => nil }
)
end
end
@ -84,7 +111,7 @@ RSpec.describe AccountRelationshipsPresenter do
let(:options) { { domain_blocking_map: { 7 => true } } }
it 'sets @domain_blocking merged with default_map and options[:domain_blocking_map]' do
expect(presenter.domain_blocking).to eq default_map.merge(options[:domain_blocking_map])
expect(presenter.domain_blocking).to eq({ accounts[0].id => nil }.merge(options[:domain_blocking_map]))
end
end
end

View file

@ -0,0 +1,398 @@
# frozen_string_literal: true
require 'rails_helper'
describe 'signature verification concern' do
before do
stub_tests_controller
# Signature checking is time-dependent, so travel to a fixed date
travel_to '2023-12-20T10:00:00Z'
end
after { Rails.application.reload_routes! }
# Include the private key so the tests can be easily adjusted and reviewed
let(:actor_keypair) do
OpenSSL::PKey.read(<<~PEM_TEXT)
-----BEGIN RSA PRIVATE KEY-----
MIIEowIBAAKCAQEAqIAYvNFGbZ5g4iiK6feSdXD4bDStFM58A7tHycYXaYtzZQpI
eHXAmaXuZzXIwtrP4N0gIk8JNwZvXj2UPS+S07t0V9wNK94he01LV5EMz/GN4eNn
FmDL64HIEuKLvV8TvgjbUPRD6Y5X0UpKi2ZIFLSb96Q5w0Z/k7ntpVKV52y8kz5F
jr/O/0JuHryZe0yItzJh8kzFfeMf0EXzfSnaKvT7P9jhgC6uTre+jXyvVZjiHDrn
qvvucdI3I7DRfXo1OqARBrLjy+TdseUAjNYJ+OuPRI1URIWQI01DCHqcohVu9+Ar
+BiCjFp3ua+XMuJvrvbD61d1Fvig/9nbBRR+8QIDAQABAoIBAAgySHnFWI6gItR3
fkfiqIm80cHCN3Xk1C6iiVu+3oBOZbHpW9R7vl9e/WOA/9O+LPjiSsQOegtWnVvd
RRjrl7Hj20VDlZKv5Mssm6zOGAxksrcVbqwdj+fUJaNJCL0AyyseH0x/IE9T8rDC
I1GH+3tB3JkhkIN/qjipdX5ab8MswEPu8IC4ViTpdBgWYY/xBcAHPw4xuL0tcwzh
FBlf4DqoEVQo8GdK5GAJ2Ny0S4xbXHUURzx/R4y4CCts7niAiLGqd9jmLU1kUTMk
QcXfQYK6l+unLc7wDYAz7sFEHh04M48VjWwiIZJnlCqmQbLda7uhhu8zkF1DqZTu
ulWDGQECgYEA0TIAc8BQBVab979DHEEmMdgqBwxLY3OIAk0b+r50h7VBGWCDPRsC
STD73fQY3lNet/7/jgSGwwAlAJ5PpMXxXiZAE3bUwPmHzgF7pvIOOLhA8O07tHSO
L2mvQe6NPzjZ+6iAO2U9PkClxcvGvPx2OBvisfHqZLmxC9PIVxzruQECgYEAzjM6
BTUXa6T/qHvLFbN699BXsUOGmHBGaLRapFDBfVvgZrwqYQcZpBBhesLdGTGSqwE7
gWsITPIJ+Ldo+38oGYyVys+w/V67q6ud7hgSDTW3hSvm+GboCjk6gzxlt9hQ0t9X
8vfDOYhEXvVUJNv3mYO60ENqQhILO4bQ0zi+VfECgYBb/nUccfG+pzunU0Cb6Dp3
qOuydcGhVmj1OhuXxLFSDG84Tazo7juvHA9mp7VX76mzmDuhpHPuxN2AzB2SBEoE
cSW0aYld413JRfWukLuYTc6hJHIhBTCRwRQFFnae2s1hUdQySm8INT2xIc+fxBXo
zrp+Ljg5Wz90SAnN5TX0AQKBgDaatDOq0o/r+tPYLHiLtfWoE4Dau+rkWJDjqdk3
lXWn/e3WyHY3Vh/vQpEqxzgju45TXjmwaVtPATr+/usSykCxzP0PMPR3wMT+Rm1F
rIoY/odij+CaB7qlWwxj0x/zRbwB7x1lZSp4HnrzBpxYL+JUUwVRxPLIKndSBTza
GvVRAoGBAIVBcNcRQYF4fvZjDKAb4fdBsEuHmycqtRCsnkGOz6ebbEQznSaZ0tZE
+JuouZaGjyp8uPjNGD5D7mIGbyoZ3KyG4mTXNxDAGBso1hrNDKGBOrGaPhZx8LgO
4VXJ+ybXrATf4jr8ccZYsZdFpOphPzz+j55Mqg5vac5P1XjmsGTb
-----END RSA PRIVATE KEY-----
PEM_TEXT
end
context 'without a Signature header' do
it 'does not treat the request as signed' do
get '/activitypub/success'
expect(response).to have_http_status(200)
expect(body_as_json).to match(
signed_request: false,
signature_actor_id: nil,
error: 'Request not signed'
)
end
context 'when a signature is required' do
it 'returns http unauthorized with appropriate error' do
get '/activitypub/signature_required'
expect(response).to have_http_status(401)
expect(body_as_json).to match(
error: 'Request not signed'
)
end
end
end
context 'with an HTTP Signature from a known account' do
let!(:actor) { Fabricate(:account, domain: 'remote.domain', uri: 'https://remote.domain/users/bob', private_key: nil, public_key: actor_keypair.public_key.to_pem) }
context 'with a valid signature on a GET request' 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', 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 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
it 'fails to verify signature', :aggregate_failures do
get '/activitypub/alternative-path', headers: {
'Host' => 'www.example.com',
'Date' => 'Wed, 20 Dec 2023 10:00:00 GMT',
'Signature' => '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
}
expect(body_as_json).to match(
signed_request: true,
signature_actor_id: nil,
error: anything
)
end
end
context 'with a mismatching method' do
it 'fails to verify signature', :aggregate_failures do
post '/activitypub/success', headers: {
'Host' => 'www.example.com',
'Date' => 'Wed, 20 Dec 2023 10:00:00 GMT',
'Signature' => '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
}
expect(body_as_json).to match(
signed_request: true,
signature_actor_id: nil,
error: anything
)
end
end
context 'with an unparsable date' do
let(:signature_header) do
'keyId="https://remote.domain/users/bob#main-key",algorithm="rsa-sha256",headers="date host (request-target)",signature="d4B7nfx8RJcfdJDu1J//5WzPzK/hgtPkdzZx49lu5QhnE7qdV3lgyVimmhCFrO16bwvzIp9iRMyRLkNFxLiEeVaa1gqeKbldGSnU0B0OMjx7rFBa65vLuzWQOATDitVGiBEYqoK4v0DMuFCz2DtFaA/DIUZ3sty8bZ/Ea3U1nByLOO6MacARA3zhMSI0GNxGqsSmZmG0hPLavB3jIXoE3IDoQabMnC39jrlcO/a8h1iaxBm2WD8TejrImJullgqlJIFpKhIHI3ipQkvTGPlm9dx0y+beM06qBvWaWQcmT09eRIUefVsOAzIhUtS/7FVb/URhZvircIJDa7vtiFcmZQ=="' # 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', { 'Date' => 'wrong date', 'Host' => 'www.example.com' })
get '/activitypub/success', headers: {
'Host' => 'www.example.com',
'Date' => 'wrong date',
'Signature' => signature_header,
}
expect(body_as_json).to match(
signed_request: true,
signature_actor_id: nil,
error: 'Invalid Date header: not RFC 2616 compliant date: "wrong date"'
)
end
end
context 'with a request older than a day' do
let(:signature_header) do
'keyId="https://remote.domain/users/bob#main-key",algorithm="rsa-sha256",headers="date host (request-target)",signature="G1NuJv4zgoZ3B/ZIjzDWZHK4RC+5pYee74q8/LJEMCWXhcnAomcb9YHaqk1QYfQvcBUIXw3UZ3Q9xO8F9y0i8G5mzJHfQ+OgHqCoJk8EmGwsUXJMh5s1S5YFCRt8TT12TmJZz0VMqLq85ubueSYBM7QtUE/FzFIVLvz4RysgXxaXQKzdnM6+gbUEEKdCURpXdQt2NXQhp4MAmZH3+0lQoR6VxdsK0hx0Ji2PNp1nuqFTlYqNWZazVdLBN+9rETLRmvGXknvg9jOxTTppBVWnkAIl26HtLS3wwFVvz4pJzi9OQDOvLziehVyLNbU61hky+oJ215e2HuKSe2hxHNl1MA=="' # 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', { 'Date' => 'Wed, 18 Dec 2023 10:00:00 GMT', 'Host' => 'www.example.com' })
get '/activitypub/success', headers: {
'Host' => 'www.example.com',
'Date' => 'Wed, 18 Dec 2023 10:00:00 GMT',
'Signature' => signature_header,
}
expect(body_as_json).to match(
signed_request: true,
signature_actor_id: nil,
error: 'Signed request date outside acceptable time window'
)
end
end
context 'with a valid signature on a POST request' do
let(:digest_header) { 'SHA-256=ZOyIygCyaOW6GjVnihtTFtIS9PNmskdyMlNKiuyjfzw=' }
let(:signature_header) do
'keyId="https://remote.domain/users/bob#main-key",algorithm="rsa-sha256",headers="host date digest (request-target)",signature="gmhMjgMROGElJU3fpehV2acD5kMHeELi8EFP2UPHOdQ54H0r55AxIpji+J3lPe+N2qSb/4H1KXIh6f0lRu8TGSsu12OQmg5hiO8VA9flcA/mh9Lpk+qwlQZIPRqKP9xUEfqD+Z7ti5wPzDKrWAUK/7FIqWgcT/mlqB1R1MGkpMFc/q4CIs2OSNiWgA4K+Kp21oQxzC2kUuYob04gAZ7cyE/FTia5t08uv6lVYFdRsn4XNPn1MsHgFBwBMRG79ng3SyhoG4PrqBEi5q2IdLq3zfre/M6He3wlCpyO2VJNdGVoTIzeZ0Zz8jUscPV3XtWUchpGclLGSaKaq/JyNZeiYQ=="' # rubocop:disable Layout/LineLength
end
it 'successfuly verifies signature', :aggregate_failures do
expect(digest_header).to eq digest_value('Hello world')
expect(signature_header).to eq build_signature_string(actor_keypair, 'https://remote.domain/users/bob#main-key', 'post /activitypub/success', { 'Host' => 'www.example.com', 'Date' => 'Wed, 20 Dec 2023 10:00:00 GMT', 'Digest' => digest_header })
post '/activitypub/success', params: 'Hello world', headers: {
'Host' => 'www.example.com',
'Date' => 'Wed, 20 Dec 2023 10:00:00 GMT',
'Digest' => digest_header,
'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 Digest of a POST request is not signed' do
let(:digest_header) { 'SHA-256=ZOyIygCyaOW6GjVnihtTFtIS9PNmskdyMlNKiuyjfzw=' }
let(:signature_header) do
'keyId="https://remote.domain/users/bob#main-key",algorithm="rsa-sha256",headers="host date (request-target)",signature="CPD704CG8aCm8X8qIP8kkkiGp1qwFLk/wMVQHOGP0Txxan8c2DZtg/KK7eN8RG8tHx8br/yS2hJs51x4kXImYukGzNJd7ihE3T8lp+9RI1tCcdobTzr/VcVJHDFySdQkg266GCMijRQRZfNvqlJLiisr817PI+gNVBI5qV+vnVd1XhWCEZ+YSmMe8UqYARXAYNqMykTheojqGpTeTFGPUpTQA2Fmt2BipwIjcFDm2Hpihl2kB0MUS0x3zPmHDuadvzoBbN6m3usPDLgYrpALlh+wDs1dYMntcwdwawRKY1oE1XNtgOSum12wntDq3uYL4gya2iPdcw3c929b4koUzw=="' # rubocop:disable Layout/LineLength
end
it 'fails to verify signature', :aggregate_failures do
expect(digest_header).to eq digest_value('Hello world')
expect(signature_header).to eq build_signature_string(actor_keypair, 'https://remote.domain/users/bob#main-key', 'post /activitypub/success', { 'Host' => 'www.example.com', 'Date' => 'Wed, 20 Dec 2023 10:00:00 GMT' })
post '/activitypub/success', params: 'Hello world', headers: {
'Host' => 'www.example.com',
'Date' => 'Wed, 20 Dec 2023 10:00:00 GMT',
'Digest' => digest_header,
'Signature' => signature_header,
}
expect(body_as_json).to match(
signed_request: true,
signature_actor_id: nil,
error: 'Mastodon requires the Digest header to be signed when doing a POST request'
)
end
end
context 'with a tampered body on a POST request' do
let(:digest_header) { 'SHA-256=ZOyIygCyaOW6GjVnihtTFtIS9PNmskdyMlNKiuyjfzw=' }
let(:signature_header) do
'keyId="https://remote.domain/users/bob#main-key",algorithm="rsa-sha256",headers="host date digest (request-target)",signature="gmhMjgMROGElJU3fpehV2acD5kMHeELi8EFP2UPHOdQ54H0r55AxIpji+J3lPe+N2qSb/4H1KXIh6f0lRu8TGSsu12OQmg5hiO8VA9flcA/mh9Lpk+qwlQZIPRqKP9xUEfqD+Z7ti5wPzDKrWAUK/7FIqWgcT/mlqB1R1MGkpMFc/q4CIs2OSNiWgA4K+Kp21oQxzC2kUuYob04gAZ7cyE/FTia5t08uv6lVYFdRsn4XNPn1MsHgFBwBMRG79ng3SyhoG4PrqBEi5q2IdLq3zfre/M6He3wlCpyO2VJNdGVoTIzeZ0Zz8jUscPV3XtWUchpGclLGSaKaq/JyNZeiYQ=="' # rubocop:disable Layout/LineLength
end
it 'fails to verify signature', :aggregate_failures do
expect(digest_header).to_not eq digest_value('Hello world!')
expect(signature_header).to eq build_signature_string(actor_keypair, 'https://remote.domain/users/bob#main-key', 'post /activitypub/success', { 'Host' => 'www.example.com', 'Date' => 'Wed, 20 Dec 2023 10:00:00 GMT', 'Digest' => digest_header })
post '/activitypub/success', params: 'Hello world!', headers: {
'Host' => 'www.example.com',
'Date' => 'Wed, 20 Dec 2023 10:00:00 GMT',
'Digest' => 'SHA-256=ZOyIygCyaOW6GjVnihtTFtIS9PNmskdyMlNKiuyjfzw=',
'Signature' => signature_header,
}
expect(body_as_json).to match(
signed_request: true,
signature_actor_id: nil,
error: 'Invalid Digest value. Computed SHA-256 digest: wFNeS+K3n/2TKRMFQ2v4iTFOSj+uwF7P/Lt98xrZ5Ro=; given: ZOyIygCyaOW6GjVnihtTFtIS9PNmskdyMlNKiuyjfzw='
)
end
end
context 'with a tampered path in a POST request' do
it 'fails to verify signature', :aggregate_failures do
post '/activitypub/alternative-path', params: 'Hello world', headers: {
'Host' => 'www.example.com',
'Date' => 'Wed, 20 Dec 2023 10:00:00 GMT',
'Digest' => 'SHA-256=ZOyIygCyaOW6GjVnihtTFtIS9PNmskdyMlNKiuyjfzw=',
'Signature' => 'keyId="https://remote.domain/users/bob#main-key",algorithm="rsa-sha256",headers="host date digest (request-target)",signature="gmhMjgMROGElJU3fpehV2acD5kMHeELi8EFP2UPHOdQ54H0r55AxIpji+J3lPe+N2qSb/4H1KXIh6f0lRu8TGSsu12OQmg5hiO8VA9flcA/mh9Lpk+qwlQZIPRqKP9xUEfqD+Z7ti5wPzDKrWAUK/7FIqWgcT/mlqB1R1MGkpMFc/q4CIs2OSNiWgA4K+Kp21oQxzC2kUuYob04gAZ7cyE/FTia5t08uv6lVYFdRsn4XNPn1MsHgFBwBMRG79ng3SyhoG4PrqBEi5q2IdLq3zfre/M6He3wlCpyO2VJNdGVoTIzeZ0Zz8jUscPV3XtWUchpGclLGSaKaq/JyNZeiYQ=="', # rubocop:disable Layout/LineLength
}
expect(response).to have_http_status(200)
expect(body_as_json).to match(
signed_request: true,
signature_actor_id: nil,
error: anything
)
end
end
end
context 'with an inaccessible key' do
before do
stub_request(:get, 'https://remote.domain/users/alice#main-key').to_return(status: 404)
end
it 'fails to verify signature', :aggregate_failures do
get '/activitypub/success', headers: {
'Host' => 'www.example.com',
'Date' => 'Wed, 20 Dec 2023 10:00:00 GMT',
'Signature' => 'keyId="https://remote.domain/users/alice#main-key",algorithm="rsa-sha256",headers="date host (request-target)",signature="Z8ilar3J7bOwqZkMp7sL8sRs4B1FT+UorbmvWoE+A5UeoOJ3KBcUmbsh+k3wQwbP5gMNUrra9rEWabpasZGphLsbDxfbsWL3Cf0PllAc7c1c7AFEwnewtExI83/qqgEkfWc2z7UDutXc2NfgAx89Ox8DXU/fA2GG0jILjB6UpFyNugkY9rg6oI31UnvfVi3R7sr3/x8Ea3I9thPvqI2byF6cojknSpDAwYzeKdngX3TAQEGzFHz3SDWwyp3jeMWfwvVVbM38FxhvAnSumw7YwWW4L7M7h4M68isLimoT3yfCn2ucBVL5Dz8koBpYf/40w7QidClAwCafZQFC29yDOg=="', # rubocop:disable Layout/LineLength
}
expect(body_as_json).to match(
signed_request: true,
signature_actor_id: nil,
error: 'Unable to fetch key JSON at https://remote.domain/users/alice#main-key'
)
end
end
private
def stub_tests_controller
stub_const('ActivityPub::TestsController', activitypub_tests_controller)
Rails.application.routes.draw do
# NOTE: RouteSet#draw removes all routes, so we need to re-insert one
resource :instance_actor, path: 'actor', only: [:show]
match :via => [:get, :post], '/activitypub/success' => 'activitypub/tests#success'
match :via => [:get, :post], '/activitypub/alternative-path' => 'activitypub/tests#alternative_success'
match :via => [:get, :post], '/activitypub/signature_required' => 'activitypub/tests#signature_required'
end
end
def activitypub_tests_controller
Class.new(ApplicationController) do
include SignatureVerification
before_action :require_actor_signature!, only: [:signature_required]
def success
render json: {
signed_request: signed_request?,
signature_actor_id: signed_request_actor&.id&.to_s,
}.merge(signature_verification_failure_reason || {})
end
alias_method :alternative_success, :success
alias_method :signature_required, :success
end
end
def digest_value(body)
"SHA-256=#{Digest::SHA256.base64digest(body)}"
end
def build_signature_string(keypair, key_id, request_target, headers)
algorithm = 'rsa-sha256'
signed_headers = headers.merge({ '(request-target)' => request_target })
signed_string = signed_headers.map { |key, value| "#{key.downcase}: #{value}" }.join("\n")
signature = Base64.strict_encode64(keypair.sign(OpenSSL::Digest.new('SHA256'), signed_string))
"keyId=\"#{key_id}\",algorithm=\"#{algorithm}\",headers=\"#{signed_headers.keys.join(' ').downcase}\",signature=\"#{signature}\""
end
end

View file

@ -31,7 +31,7 @@ RSpec.describe ActivityPub::FetchFeaturedCollectionService, type: :service do
}
end
let(:status_json_pinned_unknown_unreachable) do
let(:status_json_pinned_unknown_reachable) do
{
'@context': 'https://www.w3.org/ns/activitystreams',
type: 'Note',
@ -65,7 +65,7 @@ RSpec.describe ActivityPub::FetchFeaturedCollectionService, type: :service do
stub_request(:get, 'https://example.com/account/pinned/known').to_return(status: 200, body: Oj.dump(status_json_pinned_known))
stub_request(:get, 'https://example.com/account/pinned/unknown-inlined').to_return(status: 200, body: Oj.dump(status_json_pinned_unknown_inlined))
stub_request(:get, 'https://example.com/account/pinned/unknown-unreachable').to_return(status: 404)
stub_request(:get, 'https://example.com/account/pinned/unknown-reachable').to_return(status: 200, body: Oj.dump(status_json_pinned_unknown_unreachable))
stub_request(:get, 'https://example.com/account/pinned/unknown-reachable').to_return(status: 200, body: Oj.dump(status_json_pinned_unknown_reachable))
subject.call(actor, note: true, hashtag: false)
end
@ -103,6 +103,21 @@ RSpec.describe ActivityPub::FetchFeaturedCollectionService, type: :service do
end
it_behaves_like 'sets pinned posts'
context 'when there is a single item, with the array compacted away' do
let(:items) { 'https://example.com/account/pinned/unknown-reachable' }
before do
stub_request(:get, 'https://example.com/account/pinned/unknown-reachable').to_return(status: 200, body: Oj.dump(status_json_pinned_unknown_reachable))
subject.call(actor, note: true, hashtag: false)
end
it 'sets expected posts as pinned posts' do
expect(actor.pinned_statuses.pluck(:uri)).to contain_exactly(
'https://example.com/account/pinned/unknown-reachable'
)
end
end
end
context 'when the endpoint is a paginated Collection' do
@ -124,6 +139,21 @@ RSpec.describe ActivityPub::FetchFeaturedCollectionService, type: :service do
end
it_behaves_like 'sets pinned posts'
context 'when there is a single item, with the array compacted away' do
let(:items) { 'https://example.com/account/pinned/unknown-reachable' }
before do
stub_request(:get, 'https://example.com/account/pinned/unknown-reachable').to_return(status: 200, body: Oj.dump(status_json_pinned_unknown_reachable))
subject.call(actor, note: true, hashtag: false)
end
it 'sets expected posts as pinned posts' do
expect(actor.pinned_statuses.pluck(:uri)).to contain_exactly(
'https://example.com/account/pinned/unknown-reachable'
)
end
end
end
end
end

View file

@ -34,6 +34,18 @@ RSpec.describe ActivityPub::FetchRepliesService, type: :service do
describe '#call' do
context 'when the payload is a Collection with inlined replies' do
context 'when there is a single reply, with the array compacted away' do
let(:items) { 'http://example.com/self-reply-1' }
it 'queues the expected worker' do
allow(FetchReplyWorker).to receive(:push_bulk)
subject.call(status, payload)
expect(FetchReplyWorker).to have_received(:push_bulk).with(['http://example.com/self-reply-1'])
end
end
context 'when passing the collection itself' do
it 'spawns workers for up to 5 replies on the same server' do
expect(FetchReplyWorker).to receive(:push_bulk).with(['http://example.com/self-reply-1', 'http://example.com/self-reply-2', 'http://example.com/self-reply-3', 'http://example.com/self-reply-4', 'http://example.com/self-reply-5'])

View file

@ -86,9 +86,5 @@ RSpec.describe ReblogService, type: :service do
it 'distributes to followers' do
expect(ActivityPub::DistributionWorker).to have_received(:perform_async)
end
it 'sends an announce activity to the author' do
expect(a_request(:post, bob.inbox_url)).to have_been_made.once
end
end
end

View file

@ -110,4 +110,22 @@ RSpec.describe RemoveStatusService, type: :service do
)).to have_been_made.once
end
end
context 'when removed status is a reblog of a non-follower' do
let!(:original_status) { Fabricate(:status, account: bill, text: 'Hello ThisIsASecret', visibility: :public) }
let!(:status) { ReblogService.new.call(alice, original_status) }
it 'sends Undo activity to followers' do
subject.call(status)
expect(a_request(:post, bill.inbox_url).with(
body: hash_including({
'type' => 'Undo',
'object' => hash_including({
'type' => 'Announce',
'object' => ActivityPub::TagManager.instance.uri_for(original_status),
}),
})
)).to have_been_made.once
end
end
end