Compare commits
14 commits
52d2fb8560
...
bd7ce0d5f9
Author | SHA1 | Date | |
---|---|---|---|
bd7ce0d5f9 | |||
|
fc4e2eca9f | ||
|
2e8943aecd | ||
|
e6072a8d13 | ||
|
460e4fbdd6 | ||
|
de60322711 | ||
|
90bb870680 | ||
|
9292d998fe | ||
|
92643f48de | ||
|
458620bdd4 | ||
|
a1a71263e0 | ||
|
4c5575e8e0 | ||
|
a2ddd849e2 | ||
|
2e4d43933d |
30 changed files with 776 additions and 362 deletions
18
CHANGELOG.md
18
CHANGELOG.md
|
@ -3,6 +3,24 @@ Changelog
|
||||||
|
|
||||||
All notable changes to this project will be documented in this file.
|
All notable changes to this project will be documented in this file.
|
||||||
|
|
||||||
|
## [4.1.12] - 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 `Undo Announce` activity not being sent to non-follower authors ([MitarashiDango](https://github.com/mastodon/mastodon/pull/18482))
|
||||||
|
- Fix `LinkCrawlWorker` error when encountering empty OEmbed response ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/28268))
|
||||||
|
|
||||||
|
### Security
|
||||||
|
|
||||||
|
- Add rate-limit of TOTP authentication attempts at controller level ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/28801))
|
||||||
|
|
||||||
## [4.1.11] - 2023-12-04
|
## [4.1.11] - 2023-12-04
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|
|
@ -2,7 +2,7 @@
|
||||||
|
|
||||||
class Api::V1::StreamingController < Api::BaseController
|
class Api::V1::StreamingController < Api::BaseController
|
||||||
def index
|
def index
|
||||||
if Rails.configuration.x.streaming_api_base_url == request.host
|
if same_host?
|
||||||
not_found
|
not_found
|
||||||
else
|
else
|
||||||
redirect_to streaming_api_url, status: 301
|
redirect_to streaming_api_url, status: 301
|
||||||
|
@ -11,9 +11,16 @@ class Api::V1::StreamingController < Api::BaseController
|
||||||
|
|
||||||
private
|
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
|
def streaming_api_url
|
||||||
Addressable::URI.parse(request.url).tap do |uri|
|
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.to_s
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -1,6 +1,10 @@
|
||||||
# frozen_string_literal: true
|
# frozen_string_literal: true
|
||||||
|
|
||||||
class Auth::SessionsController < Devise::SessionsController
|
class Auth::SessionsController < Devise::SessionsController
|
||||||
|
include Redisable
|
||||||
|
|
||||||
|
MAX_2FA_ATTEMPTS_PER_HOUR = 10
|
||||||
|
|
||||||
layout 'auth'
|
layout 'auth'
|
||||||
|
|
||||||
skip_before_action :require_no_authentication, only: [:create]
|
skip_before_action :require_no_authentication, only: [:create]
|
||||||
|
@ -136,9 +140,23 @@ class Auth::SessionsController < Devise::SessionsController
|
||||||
session.delete(:attempt_user_updated_at)
|
session.delete(:attempt_user_updated_at)
|
||||||
end
|
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)
|
def on_authentication_success(user, security_measure)
|
||||||
@on_authentication_success_called = true
|
@on_authentication_success_called = true
|
||||||
|
|
||||||
|
clear_2fa_attempt_from_user(user)
|
||||||
clear_attempt_from_session
|
clear_attempt_from_session
|
||||||
|
|
||||||
user.update_sign_in!(new_sign_in: true)
|
user.update_sign_in!(new_sign_in: true)
|
||||||
|
@ -170,4 +188,8 @@ class Auth::SessionsController < Devise::SessionsController
|
||||||
user_agent: request.user_agent
|
user_agent: request.user_agent
|
||||||
)
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def second_factor_attempts_key(user)
|
||||||
|
"2fa_auth_attempts:#{user.id}:#{Time.now.utc.hour}"
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -91,14 +91,23 @@ module SignatureVerification
|
||||||
raise SignatureVerificationError, "Public key not found for key #{signature_params['keyId']}" if actor.nil?
|
raise SignatureVerificationError, "Public key not found for key #{signature_params['keyId']}" if actor.nil?
|
||||||
|
|
||||||
signature = Base64.decode64(signature_params['signature'])
|
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?
|
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) }
|
actor = stoplight_wrap_request { actor_refresh_key!(actor) }
|
||||||
|
|
||||||
raise SignatureVerificationError, "Could not refresh public key #{signature_params['keyId']}" if actor.nil?
|
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?
|
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']
|
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']
|
||||||
|
@ -177,16 +186,24 @@ module SignatureVerification
|
||||||
nil
|
nil
|
||||||
end
|
end
|
||||||
|
|
||||||
def build_signed_string
|
def build_signed_string(include_query_string: true)
|
||||||
signed_headers.map do |signed_header|
|
signed_headers.map do |signed_header|
|
||||||
if signed_header == Request::REQUEST_TARGET
|
case signed_header
|
||||||
"#{Request::REQUEST_TARGET}: #{request.method.downcase} #{request.path}"
|
when Request::REQUEST_TARGET
|
||||||
elsif signed_header == '(created)'
|
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, '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?
|
raise SignatureVerificationError, 'Pseudo-header (created) used but corresponding argument missing' if signature_params['created'].blank?
|
||||||
|
|
||||||
"(created): #{signature_params['created']}"
|
"(created): #{signature_params['created']}"
|
||||||
elsif signed_header == '(expires)'
|
when '(expires)'
|
||||||
raise SignatureVerificationError, 'Invalid pseudo-header (expires) for rsa-sha256' unless signature_algorithm == 'hs2019'
|
raise SignatureVerificationError, 'Invalid pseudo-header (expires) for rsa-sha256' unless signature_algorithm == 'hs2019'
|
||||||
raise SignatureVerificationError, 'Pseudo-header (expires) used but corresponding argument missing' if signature_params['expires'].blank?
|
raise SignatureVerificationError, 'Pseudo-header (expires) used but corresponding argument missing' if signature_params['expires'].blank?
|
||||||
|
|
||||||
|
|
|
@ -65,6 +65,11 @@ module TwoFactorAuthenticationConcern
|
||||||
end
|
end
|
||||||
|
|
||||||
def authenticate_with_two_factor_via_otp(user)
|
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)
|
if valid_otp_attempt?(user)
|
||||||
on_authentication_success(user, :otp)
|
on_authentication_success(user, :otp)
|
||||||
else
|
else
|
||||||
|
|
|
@ -157,7 +157,7 @@ module JsonLdHelper
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def fetch_resource(uri, id, on_behalf_of = nil)
|
def fetch_resource(uri, id, on_behalf_of = nil, request_options: {})
|
||||||
unless id
|
unless id
|
||||||
json = fetch_resource_without_id_validation(uri, on_behalf_of)
|
json = fetch_resource_without_id_validation(uri, on_behalf_of)
|
||||||
|
|
||||||
|
@ -166,14 +166,14 @@ module JsonLdHelper
|
||||||
uri = json['id']
|
uri = json['id']
|
||||||
end
|
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
|
json.present? && json['id'] == uri ? json : nil
|
||||||
end
|
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
|
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
|
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
|
body_to_json(response.body_with_limit) if response.code == 200
|
||||||
|
@ -206,8 +206,8 @@ module JsonLdHelper
|
||||||
response.code == 501 || ((400...500).cover?(response.code) && ![401, 408, 429].include?(response.code))
|
response.code == 501 || ((400...500).cover?(response.code) && ![401, 408, 429].include?(response.code))
|
||||||
end
|
end
|
||||||
|
|
||||||
def build_request(uri, on_behalf_of = nil)
|
def build_request(uri, on_behalf_of = nil, options: {})
|
||||||
Request.new(:get, uri).tap do |request|
|
Request.new(:get, uri, **options).tap do |request|
|
||||||
request.on_behalf_of(on_behalf_of) if on_behalf_of
|
request.on_behalf_of(on_behalf_of) if on_behalf_of
|
||||||
request.add_headers('Accept' => 'application/activity+json, application/ld+json')
|
request.add_headers('Accept' => 'application/activity+json, application/ld+json')
|
||||||
end
|
end
|
||||||
|
|
|
@ -77,6 +77,7 @@ class Request
|
||||||
@url = Addressable::URI.parse(url).normalize
|
@url = Addressable::URI.parse(url).normalize
|
||||||
@http_client = options.delete(:http_client)
|
@http_client = options.delete(:http_client)
|
||||||
@allow_local = options.delete(:allow_local)
|
@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(socket_class: use_proxy? || @allow_local ? ProxySocket : Socket)
|
||||||
@options = @options.merge(timeout_class: PerOperationWithDeadline, timeout_options: TIMEOUT)
|
@options = @options.merge(timeout_class: PerOperationWithDeadline, timeout_options: TIMEOUT)
|
||||||
@options = @options.merge(proxy_url) if use_proxy?
|
@options = @options.merge(proxy_url) if use_proxy?
|
||||||
|
@ -146,7 +147,7 @@ class Request
|
||||||
private
|
private
|
||||||
|
|
||||||
def set_common_headers!
|
def set_common_headers!
|
||||||
@headers[REQUEST_TARGET] = "#{@verb} #{@url.path}"
|
@headers[REQUEST_TARGET] = request_target
|
||||||
@headers['User-Agent'] = Mastodon::Version.user_agent
|
@headers['User-Agent'] = Mastodon::Version.user_agent
|
||||||
@headers['Host'] = @url.host
|
@headers['Host'] = @url.host
|
||||||
@headers['Date'] = Time.now.utc.httpdate
|
@headers['Date'] = Time.now.utc.httpdate
|
||||||
|
@ -157,6 +158,14 @@ class Request
|
||||||
@headers['Digest'] = "SHA-256=#{Digest::SHA256.base64digest(@options[:body])}"
|
@headers['Digest'] = "SHA-256=#{Digest::SHA256.base64digest(@options[:body])}"
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def request_target
|
||||||
|
if @url.query.nil? || !@full_path
|
||||||
|
"#{@verb} #{@url.path}"
|
||||||
|
else
|
||||||
|
"#{@verb} #{@url.path}?#{@url.query}"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
def signature
|
def signature
|
||||||
algorithm = 'rsa-sha256'
|
algorithm = 'rsa-sha256'
|
||||||
signature = Base64.strict_encode64(@keypair.sign(OpenSSL::Digest.new('SHA256'), signed_string))
|
signature = Base64.strict_encode64(@keypair.sign(OpenSSL::Digest.new('SHA256'), signed_string))
|
||||||
|
|
|
@ -16,28 +16,28 @@ class StatusReachFinder
|
||||||
private
|
private
|
||||||
|
|
||||||
def reached_account_inboxes
|
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
|
# When the status is a reblog, there are no interactions with it
|
||||||
# directly, we assume all interactions are with the original one
|
# directly, we assume all interactions are with the original one
|
||||||
|
|
||||||
if @status.reblog?
|
if @status.reblog?
|
||||||
[]
|
[reblog_of_account_id]
|
||||||
else
|
else
|
||||||
Account.where(id: reached_account_ids).inboxes
|
[
|
||||||
end
|
replied_to_account_id,
|
||||||
end
|
reblog_of_account_id,
|
||||||
|
mentioned_account_ids,
|
||||||
def reached_account_ids
|
reblogs_account_ids,
|
||||||
[
|
favourites_account_ids,
|
||||||
replied_to_account_id,
|
replies_account_ids,
|
||||||
reblog_of_account_id,
|
].tap do |arr|
|
||||||
mentioned_account_ids,
|
arr.flatten!
|
||||||
reblogs_account_ids,
|
arr.compact!
|
||||||
favourites_account_ids,
|
arr.uniq!
|
||||||
replies_account_ids,
|
end
|
||||||
].tap do |arr|
|
|
||||||
arr.flatten!
|
|
||||||
arr.compact!
|
|
||||||
arr.uniq!
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
|
@ -112,7 +112,7 @@ class Account < ApplicationRecord
|
||||||
scope :matches_domain, ->(value) { where(arel_table[:domain].matches("%#{value}%")) }
|
scope :matches_domain, ->(value) { where(arel_table[:domain].matches("%#{value}%")) }
|
||||||
scope :without_unapproved, -> { left_outer_joins(:user).remote.or(left_outer_joins(:user).merge(User.approved.confirmed)) }
|
scope :without_unapproved, -> { left_outer_joins(:user).remote.or(left_outer_joins(:user).merge(User.approved.confirmed)) }
|
||||||
scope :searchable, -> { without_unapproved.without_suspended.where(moved_to_account_id: nil) }
|
scope :searchable, -> { without_unapproved.without_suspended.where(moved_to_account_id: nil) }
|
||||||
scope :discoverable, -> { searchable.without_silenced.where(discoverable: true).left_outer_joins(:account_stat) }
|
scope :discoverable, -> { searchable.without_silenced.where(discoverable: true).joins(:account_stat) }
|
||||||
scope :followable_by, ->(account) { joins(arel_table.join(Follow.arel_table, Arel::Nodes::OuterJoin).on(arel_table[:id].eq(Follow.arel_table[:target_account_id]).and(Follow.arel_table[:account_id].eq(account.id))).join_sources).where(Follow.arel_table[:id].eq(nil)).joins(arel_table.join(FollowRequest.arel_table, Arel::Nodes::OuterJoin).on(arel_table[:id].eq(FollowRequest.arel_table[:target_account_id]).and(FollowRequest.arel_table[:account_id].eq(account.id))).join_sources).where(FollowRequest.arel_table[:id].eq(nil)) }
|
scope :followable_by, ->(account) { joins(arel_table.join(Follow.arel_table, Arel::Nodes::OuterJoin).on(arel_table[:id].eq(Follow.arel_table[:target_account_id]).and(Follow.arel_table[:account_id].eq(account.id))).join_sources).where(Follow.arel_table[:id].eq(nil)).joins(arel_table.join(FollowRequest.arel_table, Arel::Nodes::OuterJoin).on(arel_table[:id].eq(FollowRequest.arel_table[:target_account_id]).and(FollowRequest.arel_table[:account_id].eq(account.id))).join_sources).where(FollowRequest.arel_table[:id].eq(nil)) }
|
||||||
scope :by_recent_status, -> { includes(:account_stat).merge(AccountStat.order('last_status_at DESC NULLS LAST')).references(:account_stat) }
|
scope :by_recent_status, -> { includes(:account_stat).merge(AccountStat.order('last_status_at DESC NULLS LAST')).references(:account_stat) }
|
||||||
scope :by_recent_sign_in, -> { order(Arel.sql('users.current_sign_in_at DESC NULLS LAST')) }
|
scope :by_recent_sign_in, -> { order(Arel.sql('users.current_sign_in_at DESC NULLS LAST')) }
|
||||||
|
|
|
@ -23,9 +23,9 @@ class ActivityPub::FetchFeaturedCollectionService < BaseService
|
||||||
|
|
||||||
case collection['type']
|
case collection['type']
|
||||||
when 'Collection', 'CollectionPage'
|
when 'Collection', 'CollectionPage'
|
||||||
collection['items']
|
as_array(collection['items'])
|
||||||
when 'OrderedCollection', 'OrderedCollectionPage'
|
when 'OrderedCollection', 'OrderedCollectionPage'
|
||||||
collection['orderedItems']
|
as_array(collection['orderedItems'])
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
|
@ -26,9 +26,9 @@ class ActivityPub::FetchRepliesService < BaseService
|
||||||
|
|
||||||
case collection['type']
|
case collection['type']
|
||||||
when 'Collection', 'CollectionPage'
|
when 'Collection', 'CollectionPage'
|
||||||
collection['items']
|
as_array(collection['items'])
|
||||||
when 'OrderedCollection', 'OrderedCollectionPage'
|
when 'OrderedCollection', 'OrderedCollectionPage'
|
||||||
collection['orderedItems']
|
as_array(collection['orderedItems'])
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@ -36,7 +36,21 @@ class ActivityPub::FetchRepliesService < BaseService
|
||||||
return collection_or_uri if collection_or_uri.is_a?(Hash)
|
return collection_or_uri if collection_or_uri.is_a?(Hash)
|
||||||
return unless @allow_synchronous_requests
|
return unless @allow_synchronous_requests
|
||||||
return if invalid_origin?(collection_or_uri)
|
return if invalid_origin?(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
|
end
|
||||||
|
|
||||||
def filtered_replies
|
def filtered_replies
|
||||||
|
|
|
@ -59,9 +59,9 @@ class ActivityPub::SynchronizeFollowersService < BaseService
|
||||||
|
|
||||||
case collection['type']
|
case collection['type']
|
||||||
when 'Collection', 'CollectionPage'
|
when 'Collection', 'CollectionPage'
|
||||||
collection['items']
|
as_array(collection['items'])
|
||||||
when 'OrderedCollection', 'OrderedCollectionPage'
|
when 'OrderedCollection', 'OrderedCollectionPage'
|
||||||
collection['orderedItems']
|
as_array(collection['orderedItems'])
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
|
@ -100,7 +100,7 @@ class FetchOEmbedService
|
||||||
end
|
end
|
||||||
|
|
||||||
def validate(oembed)
|
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
|
end
|
||||||
|
|
||||||
def html
|
def html
|
||||||
|
|
|
@ -69,7 +69,7 @@ class Keys::QueryService < BaseService
|
||||||
|
|
||||||
return if json['items'].blank?
|
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'])
|
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
|
end
|
||||||
rescue HTTP::Error, OpenSSL::SSL::SSLError, Mastodon::Error => e
|
rescue HTTP::Error, OpenSSL::SSL::SSLError, Mastodon::Error => e
|
||||||
|
|
|
@ -45,11 +45,7 @@ class ReblogService < BaseService
|
||||||
def create_notification(reblog)
|
def create_notification(reblog)
|
||||||
reblogged_status = reblog.reblog
|
reblogged_status = reblog.reblog
|
||||||
|
|
||||||
if reblogged_status.account.local?
|
LocalNotificationWorker.perform_async(reblogged_status.account_id, reblog.id, reblog.class.name, '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
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def bump_potential_friendship(account, reblog)
|
def bump_potential_friendship(account, reblog)
|
||||||
|
|
|
@ -7,7 +7,7 @@ class LinkCrawlWorker
|
||||||
|
|
||||||
def perform(status_id)
|
def perform(status_id)
|
||||||
FetchLinkCardService.new.call(Status.find(status_id))
|
FetchLinkCardService.new.call(Status.find(status_id))
|
||||||
rescue ActiveRecord::RecordNotFound
|
rescue ActiveRecord::RecordNotFound, ActiveRecord::RecordNotUnique
|
||||||
true
|
true
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -1684,6 +1684,7 @@ en:
|
||||||
follow_limit_reached: You cannot follow more than %{limit} people
|
follow_limit_reached: You cannot follow more than %{limit} people
|
||||||
invalid_otp_token: Invalid two-factor code
|
invalid_otp_token: Invalid two-factor code
|
||||||
otp_lost_help_html: If you lost access to both, you may get in touch with %{email}
|
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.
|
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:'
|
signed_in_as: 'Signed in as:'
|
||||||
verification:
|
verification:
|
||||||
|
|
|
@ -56,7 +56,7 @@ services:
|
||||||
|
|
||||||
web:
|
web:
|
||||||
build: .
|
build: .
|
||||||
image: ghcr.io/mastodon/mastodon:v4.1.11
|
image: ghcr.io/mastodon/mastodon:v4.1.12
|
||||||
restart: always
|
restart: always
|
||||||
env_file: .env.production
|
env_file: .env.production
|
||||||
command: bash -c "rm -f /mastodon/tmp/pids/server.pid; bundle exec rails s -p 3000"
|
command: bash -c "rm -f /mastodon/tmp/pids/server.pid; bundle exec rails s -p 3000"
|
||||||
|
@ -77,7 +77,7 @@ services:
|
||||||
|
|
||||||
streaming:
|
streaming:
|
||||||
build: .
|
build: .
|
||||||
image: ghcr.io/mastodon/mastodon:v4.1.11
|
image: ghcr.io/mastodon/mastodon:v4.1.12
|
||||||
restart: always
|
restart: always
|
||||||
env_file: .env.production
|
env_file: .env.production
|
||||||
command: node ./streaming
|
command: node ./streaming
|
||||||
|
@ -95,7 +95,7 @@ services:
|
||||||
|
|
||||||
sidekiq:
|
sidekiq:
|
||||||
build: .
|
build: .
|
||||||
image: ghcr.io/mastodon/mastodon:v4.1.11
|
image: ghcr.io/mastodon/mastodon:v4.1.12
|
||||||
restart: always
|
restart: always
|
||||||
env_file: .env.production
|
env_file: .env.production
|
||||||
command: bundle exec sidekiq
|
command: bundle exec sidekiq
|
||||||
|
|
|
@ -13,7 +13,7 @@ module Mastodon
|
||||||
end
|
end
|
||||||
|
|
||||||
def patch
|
def patch
|
||||||
11
|
12
|
||||||
end
|
end
|
||||||
|
|
||||||
def flags
|
def flags
|
||||||
|
|
|
@ -16,7 +16,7 @@ module Paperclip
|
||||||
private
|
private
|
||||||
|
|
||||||
def cache_current_values
|
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)
|
@tempfile = copy_to_tempfile(@target)
|
||||||
@content_type = ContentTypeDetector.new(@tempfile.path).detect
|
@content_type = ContentTypeDetector.new(@tempfile.path).detect
|
||||||
@size = File.size(@tempfile)
|
@size = File.size(@tempfile)
|
||||||
|
@ -43,6 +43,13 @@ module Paperclip
|
||||||
source.response.connection.close
|
source.response.connection.close
|
||||||
end
|
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
|
def filename_from_content_disposition
|
||||||
disposition = @target.response.headers['content-disposition']
|
disposition = @target.response.headers['content-disposition']
|
||||||
disposition&.match(/filename="([^"]*)"/)&.captures&.first
|
disposition&.match(/filename="([^"]*)"/)&.captures&.first
|
||||||
|
|
|
@ -5,7 +5,7 @@ require 'rails_helper'
|
||||||
describe Api::V1::StreamingController do
|
describe Api::V1::StreamingController do
|
||||||
around(:each) do |example|
|
around(:each) do |example|
|
||||||
before = Rails.configuration.x.streaming_api_base_url
|
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
|
example.run
|
||||||
Rails.configuration.x.streaming_api_base_url = before
|
Rails.configuration.x.streaming_api_base_url = before
|
||||||
end
|
end
|
||||||
|
|
|
@ -262,7 +262,27 @@ RSpec.describe Auth::SessionsController, type: :controller do
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
context 'using a valid OTP' do
|
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
|
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 }
|
post :create, params: { user: { otp_attempt: user.current_otp } }, session: { attempt_user_id: user.id, attempt_user_updated_at: user.updated_at.to_s }
|
||||||
end
|
end
|
||||||
|
|
|
@ -1,303 +0,0 @@
|
||||||
# frozen_string_literal: true
|
|
||||||
|
|
||||||
require 'rails_helper'
|
|
||||||
|
|
||||||
describe ApplicationController, type: :controller do
|
|
||||||
class WrappedActor
|
|
||||||
attr_reader :wrapped_account
|
|
||||||
|
|
||||||
def initialize(wrapped_account)
|
|
||||||
@wrapped_account = wrapped_account
|
|
||||||
end
|
|
||||||
|
|
||||||
delegate :uri, :keypair, to: :wrapped_account
|
|
||||||
end
|
|
||||||
|
|
||||||
controller 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) { WrappedActor.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 unparseable 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
|
|
|
@ -110,6 +110,14 @@ RSpec.describe ActivityPub::TagManager do
|
||||||
expect(subject.cc(status)).to include(subject.uri_for(foo))
|
expect(subject.cc(status)).to include(subject.uri_for(foo))
|
||||||
expect(subject.cc(status)).to_not include(subject.uri_for(alice))
|
expect(subject.cc(status)).to_not include(subject.uri_for(alice))
|
||||||
end
|
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
|
end
|
||||||
|
|
||||||
describe '#local_uri?' do
|
describe '#local_uri?' do
|
||||||
|
|
139
spec/requests/api/v1/directories_spec.rb
Normal file
139
spec/requests/api/v1/directories_spec.rb
Normal file
|
@ -0,0 +1,139 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
require 'rails_helper'
|
||||||
|
|
||||||
|
describe 'Directories API' do
|
||||||
|
let(:user) { Fabricate(:user, confirmed_at: nil) }
|
||||||
|
let(:token) { Fabricate(:accessible_access_token, resource_owner_id: user.id, scopes: scopes) }
|
||||||
|
let(:scopes) { 'read:follows' }
|
||||||
|
let(:headers) { { 'Authorization' => "Bearer #{token.token}" } }
|
||||||
|
|
||||||
|
describe 'GET /api/v1/directories' do
|
||||||
|
context 'with no params' do
|
||||||
|
before do
|
||||||
|
local_unconfirmed_account = Fabricate(
|
||||||
|
:account,
|
||||||
|
domain: nil,
|
||||||
|
user: Fabricate(:user, confirmed_at: nil, approved: true),
|
||||||
|
username: 'local_unconfirmed'
|
||||||
|
)
|
||||||
|
local_unconfirmed_account.create_account_stat!
|
||||||
|
|
||||||
|
local_unapproved_account = Fabricate(
|
||||||
|
:account,
|
||||||
|
domain: nil,
|
||||||
|
user: Fabricate(:user, confirmed_at: 10.days.ago),
|
||||||
|
username: 'local_unapproved'
|
||||||
|
)
|
||||||
|
local_unapproved_account.create_account_stat!
|
||||||
|
local_unapproved_account.user.update(approved: false)
|
||||||
|
|
||||||
|
local_undiscoverable_account = Fabricate(
|
||||||
|
:account,
|
||||||
|
domain: nil,
|
||||||
|
user: Fabricate(:user, confirmed_at: 10.days.ago, approved: true),
|
||||||
|
discoverable: false,
|
||||||
|
username: 'local_undiscoverable'
|
||||||
|
)
|
||||||
|
local_undiscoverable_account.create_account_stat!
|
||||||
|
|
||||||
|
excluded_from_timeline_account = Fabricate(
|
||||||
|
:account,
|
||||||
|
domain: 'host.example',
|
||||||
|
discoverable: true,
|
||||||
|
username: 'remote_excluded_from_timeline'
|
||||||
|
)
|
||||||
|
excluded_from_timeline_account.create_account_stat!
|
||||||
|
Fabricate(:block, account: user.account, target_account: excluded_from_timeline_account)
|
||||||
|
|
||||||
|
domain_blocked_account = Fabricate(
|
||||||
|
:account,
|
||||||
|
domain: 'test.example',
|
||||||
|
discoverable: true,
|
||||||
|
username: 'remote_domain_blocked'
|
||||||
|
)
|
||||||
|
domain_blocked_account.create_account_stat!
|
||||||
|
Fabricate(:account_domain_block, account: user.account, domain: 'test.example')
|
||||||
|
|
||||||
|
local_discoverable_account.create_account_stat!
|
||||||
|
eligible_remote_account.create_account_stat!
|
||||||
|
end
|
||||||
|
|
||||||
|
let(:local_discoverable_account) do
|
||||||
|
Fabricate(
|
||||||
|
:account,
|
||||||
|
domain: nil,
|
||||||
|
user: Fabricate(:user, confirmed_at: 10.days.ago, approved: true),
|
||||||
|
discoverable: true,
|
||||||
|
username: 'local_discoverable'
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
let(:eligible_remote_account) do
|
||||||
|
Fabricate(
|
||||||
|
:account,
|
||||||
|
domain: 'host.example',
|
||||||
|
discoverable: true,
|
||||||
|
username: 'eligible_remote'
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'returns the local discoverable account and the remote discoverable account' do
|
||||||
|
get '/api/v1/directory', headers: headers
|
||||||
|
|
||||||
|
expect(response).to have_http_status(200)
|
||||||
|
expect(body_as_json.size).to eq(2)
|
||||||
|
expect(body_as_json.pluck(:id)).to contain_exactly(eligible_remote_account.id.to_s, local_discoverable_account.id.to_s)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'when asking for local accounts only' do
|
||||||
|
let(:user) { Fabricate(:user, confirmed_at: 10.days.ago, approved: true) }
|
||||||
|
let(:local_account) { Fabricate(:account, domain: nil, user: user) }
|
||||||
|
let(:remote_account) { Fabricate(:account, domain: 'host.example') }
|
||||||
|
|
||||||
|
before do
|
||||||
|
local_account.create_account_stat!
|
||||||
|
remote_account.create_account_stat!
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'returns only the local accounts' do
|
||||||
|
get '/api/v1/directory', headers: headers, params: { local: '1' }
|
||||||
|
|
||||||
|
expect(response).to have_http_status(200)
|
||||||
|
expect(body_as_json.size).to eq(1)
|
||||||
|
expect(body_as_json.first[:id]).to include(local_account.id.to_s)
|
||||||
|
expect(response.body).to_not include(remote_account.id.to_s)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'when ordered by active' do
|
||||||
|
it 'returns accounts in order of most recent status activity' do
|
||||||
|
old_stat = Fabricate(:account_stat, last_status_at: 1.day.ago)
|
||||||
|
new_stat = Fabricate(:account_stat, last_status_at: 1.minute.ago)
|
||||||
|
|
||||||
|
get '/api/v1/directory', headers: headers, params: { order: 'active' }
|
||||||
|
|
||||||
|
expect(response).to have_http_status(200)
|
||||||
|
expect(body_as_json.size).to eq(2)
|
||||||
|
expect(body_as_json.first[:id]).to include(new_stat.account_id.to_s)
|
||||||
|
expect(body_as_json.second[:id]).to include(old_stat.account_id.to_s)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'when ordered by new' do
|
||||||
|
it 'returns accounts in order of creation' do
|
||||||
|
account_old = Fabricate(:account_stat).account
|
||||||
|
travel_to 10.seconds.from_now
|
||||||
|
account_new = Fabricate(:account_stat).account
|
||||||
|
|
||||||
|
get '/api/v1/directory', headers: headers, params: { order: 'new' }
|
||||||
|
|
||||||
|
expect(response).to have_http_status(200)
|
||||||
|
expect(body_as_json.size).to eq(2)
|
||||||
|
expect(body_as_json.first[:id]).to include(account_new.id.to_s)
|
||||||
|
expect(body_as_json.second[:id]).to include(account_old.id.to_s)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
398
spec/requests/signature_verification_spec.rb
Normal file
398
spec/requests/signature_verification_spec.rb
Normal 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
|
|
@ -97,6 +97,21 @@ RSpec.describe ActivityPub::FetchFeaturedCollectionService, type: :service do
|
||||||
end
|
end
|
||||||
|
|
||||||
it_behaves_like 'sets pinned posts'
|
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/4' }
|
||||||
|
|
||||||
|
before do
|
||||||
|
stub_request(:get, 'https://example.com/account/pinned/4').to_return(status: 200, body: Oj.dump(status_json_4))
|
||||||
|
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/4'
|
||||||
|
)
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
context 'when the endpoint is a paginated Collection' do
|
context 'when the endpoint is a paginated Collection' do
|
||||||
|
@ -118,6 +133,21 @@ RSpec.describe ActivityPub::FetchFeaturedCollectionService, type: :service do
|
||||||
end
|
end
|
||||||
|
|
||||||
it_behaves_like 'sets pinned posts'
|
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/4' }
|
||||||
|
|
||||||
|
before do
|
||||||
|
stub_request(:get, 'https://example.com/account/pinned/4').to_return(status: 200, body: Oj.dump(status_json_4))
|
||||||
|
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/4'
|
||||||
|
)
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -32,6 +32,18 @@ RSpec.describe ActivityPub::FetchRepliesService, type: :service do
|
||||||
|
|
||||||
describe '#call' do
|
describe '#call' do
|
||||||
context 'when the payload is a Collection with inlined replies' 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
|
context 'when passing the collection itself' do
|
||||||
it 'spawns workers for up to 5 replies on the same server' 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'])
|
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'])
|
||||||
|
|
|
@ -69,9 +69,5 @@ RSpec.describe ReblogService, type: :service do
|
||||||
it 'distributes to followers' do
|
it 'distributes to followers' do
|
||||||
expect(ActivityPub::DistributionWorker).to have_received(:perform_async)
|
expect(ActivityPub::DistributionWorker).to have_received(:perform_async)
|
||||||
end
|
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
|
||||||
end
|
end
|
||||||
|
|
|
@ -108,4 +108,22 @@ RSpec.describe RemoveStatusService, type: :service do
|
||||||
)).to have_been_made.once
|
)).to have_been_made.once
|
||||||
end
|
end
|
||||||
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
|
end
|
||||||
|
|
Loading…
Reference in a new issue