Compare commits
28 commits
v4.1.11-cw
...
master
Author | SHA1 | Date | |
---|---|---|---|
Mike Barnes | 372e262c4d | ||
b7b03e8d26 | |||
a07fff079b | |||
6f29d50aa5 | |||
9e5af6bb58 | |||
6499850ac4 | |||
6f36b633a7 | |||
d807b3960e | |||
2f6518cae2 | |||
cdbe2855f3 | |||
fdde3cdb4e | |||
ce9c641d9a | |||
Mike Barnes | 1064e395cb | ||
5799bc4af7 | |||
Mike Barnes | bd7ce0d5f9 | ||
fc4e2eca9f | |||
2e8943aecd | |||
e6072a8d13 | |||
460e4fbdd6 | |||
de60322711 | |||
90bb870680 | |||
9292d998fe | |||
92643f48de | |||
458620bdd4 | |||
a1a71263e0 | |||
4c5575e8e0 | |||
a2ddd849e2 | |||
2e4d43933d |
53
CHANGELOG.md
53
CHANGELOG.md
|
@ -3,6 +3,59 @@ Changelog
|
|||
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
## [4.1.15] - 2024-02-16
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fix OmniAuth tests and edge cases in error handling ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/29201), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/29207))
|
||||
|
||||
### Security
|
||||
|
||||
- Fix insufficient checking of remote posts ([GHSA-jhrq-qvrm-qr36](https://github.com/mastodon/mastodon/security/advisories/GHSA-jhrq-qvrm-qr36))
|
||||
|
||||
## [4.1.14] - 2024-02-14
|
||||
|
||||
### Security
|
||||
|
||||
- Update the `sidekiq-unique-jobs` dependency (see [GHSA-cmh9-rx85-xj38](https://github.com/mhenrixon/sidekiq-unique-jobs/security/advisories/GHSA-cmh9-rx85-xj38))
|
||||
In addition, we have disabled the web interface for `sidekiq-unique-jobs` out of caution.
|
||||
If you need it, you can re-enable it by setting `ENABLE_SIDEKIQ_UNIQUE_JOBS_UI=true`.
|
||||
If you only need to clear all locks, you can now use `bundle exec rake sidekiq_unique_jobs:delete_all_locks`.
|
||||
- Update the `nokogiri` dependency (see [GHSA-xc9x-jj77-9p9j](https://github.com/sparklemotion/nokogiri/security/advisories/GHSA-xc9x-jj77-9p9j))
|
||||
- Disable administrative Doorkeeper routes ([ThisIsMissEm](https://github.com/mastodon/mastodon/pull/29187))
|
||||
- Fix ongoing streaming sessions not being invalidated when applications get deleted in some cases ([GHSA-7w3c-p9j8-mq3x](https://github.com/mastodon/mastodon/security/advisories/GHSA-7w3c-p9j8-mq3x))
|
||||
In some rare cases, the streaming server was not notified of access tokens revocation on application deletion.
|
||||
- Change external authentication behavior to never reattach a new identity to an existing user by default ([GHSA-vm39-j3vx-pch3](https://github.com/mastodon/mastodon/security/advisories/GHSA-vm39-j3vx-pch3))
|
||||
Up until now, Mastodon has allowed new identities from external authentication providers to attach to an existing local user based on their verified e-mail address.
|
||||
This allowed upgrading users from a database-stored password to an external authentication provider, or move from one authentication provider to another.
|
||||
However, this behavior may be unexpected, and means that when multiple authentication providers are configured, the overall security would be that of the least secure authentication provider.
|
||||
For these reasons, this behavior is now locked under the `ALLOW_UNSAFE_AUTH_PROVIDER_REATTACH` environment variable.
|
||||
In addition, regardless of this environment variable, Mastodon will refuse to attach two identities from the same authentication provider to the same account.
|
||||
|
||||
## [4.1.13] - 2024-02-01
|
||||
|
||||
### Security
|
||||
|
||||
- Fix insufficient origin validation (CVE-2024-23832, [GHSA-3fjr-858r-92rw](https://github.com/mastodon/mastodon/security/advisories/GHSA-3fjr-858r-92rw))
|
||||
|
||||
## [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
|
||||
|
||||
### Changed
|
||||
|
|
14
Gemfile.lock
14
Gemfile.lock
|
@ -405,7 +405,7 @@ GEM
|
|||
mime-types-data (~> 3.2015)
|
||||
mime-types-data (3.2022.0105)
|
||||
mini_mime (1.1.5)
|
||||
mini_portile2 (2.8.4)
|
||||
mini_portile2 (2.8.5)
|
||||
minitest (5.17.0)
|
||||
msgpack (1.6.0)
|
||||
multi_json (1.15.0)
|
||||
|
@ -424,8 +424,8 @@ GEM
|
|||
net-protocol
|
||||
net-ssh (7.0.1)
|
||||
nio4r (2.5.9)
|
||||
nokogiri (1.14.5)
|
||||
mini_portile2 (~> 2.8.0)
|
||||
nokogiri (1.16.2)
|
||||
mini_portile2 (~> 2.8.2)
|
||||
racc (~> 1.4)
|
||||
nsa (0.2.8)
|
||||
activesupport (>= 4.2, < 7)
|
||||
|
@ -468,7 +468,7 @@ GEM
|
|||
parslet (2.0.0)
|
||||
pastel (0.8.0)
|
||||
tty-color (~> 0.5)
|
||||
pg (1.4.5)
|
||||
pg (1.4.6)
|
||||
pghero (3.1.0)
|
||||
activerecord (>= 6)
|
||||
pkg-config (1.5.1)
|
||||
|
@ -496,7 +496,7 @@ GEM
|
|||
pundit (2.3.0)
|
||||
activesupport (>= 3.0.0)
|
||||
raabro (1.4.0)
|
||||
racc (1.6.2)
|
||||
racc (1.7.3)
|
||||
rack (2.2.8)
|
||||
rack-attack (6.6.1)
|
||||
rack (>= 1.0, < 3)
|
||||
|
@ -634,7 +634,7 @@ GEM
|
|||
activerecord (>= 4.0.0)
|
||||
railties (>= 4.0.0)
|
||||
semantic_range (3.0.0)
|
||||
sidekiq (6.5.11)
|
||||
sidekiq (6.5.12)
|
||||
connection_pool (>= 2.2.5, < 3)
|
||||
rack (~> 2.0)
|
||||
redis (>= 4.5.0, < 5)
|
||||
|
@ -645,7 +645,7 @@ GEM
|
|||
rufus-scheduler (~> 3.2)
|
||||
sidekiq (>= 4, < 7)
|
||||
tilt (>= 1.4.0)
|
||||
sidekiq-unique-jobs (7.1.29)
|
||||
sidekiq-unique-jobs (7.1.33)
|
||||
brpoplpush-redis_script (> 0.1.1, <= 2.0.0)
|
||||
concurrent-ruby (~> 1.0, >= 1.0.5)
|
||||
redis (< 5.0)
|
||||
|
|
|
@ -14,6 +14,4 @@ A "vulnerability in Mastodon" is a vulnerability in the code distributed through
|
|||
| ------- | ---------------- |
|
||||
| 4.2.x | Yes |
|
||||
| 4.1.x | Yes |
|
||||
| 4.0.x | No |
|
||||
| 3.5.x | Until 2023-12-31 |
|
||||
| < 3.5 | No |
|
||||
| < 4.1 | No |
|
||||
|
|
|
@ -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
|
||||
|
@ -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
|
||||
|
|
|
@ -5,7 +5,7 @@ class Auth::OmniauthCallbacksController < Devise::OmniauthCallbacksController
|
|||
|
||||
def self.provides_callback_for(provider)
|
||||
define_method provider do
|
||||
@user = User.find_for_oauth(request.env['omniauth.auth'], current_user)
|
||||
@user = User.find_for_omniauth(request.env['omniauth.auth'], current_user)
|
||||
|
||||
if @user.persisted?
|
||||
LoginActivity.create(
|
||||
|
@ -24,6 +24,9 @@ class Auth::OmniauthCallbacksController < Devise::OmniauthCallbacksController
|
|||
session["devise.#{provider}_data"] = request.env['omniauth.auth']
|
||||
redirect_to new_user_registration_url
|
||||
end
|
||||
rescue ActiveRecord::RecordInvalid
|
||||
flash[:alert] = I18n.t('devise.failure.omniauth_user_creation_failure') if is_navigational_format?
|
||||
redirect_to new_user_session_url
|
||||
end
|
||||
end
|
||||
|
||||
|
|
|
@ -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]
|
||||
|
@ -136,9 +140,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)
|
||||
|
@ -170,4 +188,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
|
||||
|
|
|
@ -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']
|
||||
|
@ -177,16 +186,24 @@ module SignatureVerification
|
|||
nil
|
||||
end
|
||||
|
||||
def build_signed_string
|
||||
def build_signed_string(include_query_string: true)
|
||||
signed_headers.map do |signed_header|
|
||||
if signed_header == Request::REQUEST_TARGET
|
||||
"#{Request::REQUEST_TARGET}: #{request.method.downcase} #{request.path}"
|
||||
elsif signed_header == '(created)'
|
||||
case signed_header
|
||||
when Request::REQUEST_TARGET
|
||||
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?
|
||||
|
||||
"(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, 'Pseudo-header (expires) used but corresponding argument missing' if signature_params['expires'].blank?
|
||||
|
||||
|
@ -246,7 +263,7 @@ module SignatureVerification
|
|||
stoplight_wrap_request { ResolveAccountService.new.call(key_id.gsub(/\Aacct:/, ''), suppress_errors: false) }
|
||||
elsif !ActivityPub::TagManager.instance.local_uri?(key_id)
|
||||
account = ActivityPub::TagManager.instance.uri_to_actor(key_id)
|
||||
account ||= stoplight_wrap_request { ActivityPub::FetchRemoteKeyService.new.call(key_id, id: false, suppress_errors: false) }
|
||||
account ||= stoplight_wrap_request { ActivityPub::FetchRemoteKeyService.new.call(key_id, suppress_errors: false) }
|
||||
account
|
||||
end
|
||||
rescue Mastodon::PrivateNetworkAddressError => e
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -157,8 +157,8 @@ module JsonLdHelper
|
|||
end
|
||||
end
|
||||
|
||||
def fetch_resource(uri, id, on_behalf_of = nil)
|
||||
unless id
|
||||
def fetch_resource(uri, id_is_known, on_behalf_of = nil, request_options: {})
|
||||
unless id_is_known
|
||||
json = fetch_resource_without_id_validation(uri, on_behalf_of)
|
||||
|
||||
return if !json.is_a?(Hash) || unsupported_uri_scheme?(json['id'])
|
||||
|
@ -166,17 +166,29 @@ 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
|
||||
body_to_json(response.body_with_limit) if response.code == 200 && valid_activitypub_content_type?(response)
|
||||
end
|
||||
end
|
||||
|
||||
def valid_activitypub_content_type?(response)
|
||||
return true if response.mime_type == 'application/activity+json'
|
||||
|
||||
# When the mime type is `application/ld+json`, we need to check the profile,
|
||||
# but `http.rb` does not parse it for us.
|
||||
return false unless response.mime_type == 'application/ld+json'
|
||||
|
||||
response.headers[HTTP::Headers::CONTENT_TYPE]&.split(';')&.map(&:strip)&.any? do |str|
|
||||
str.start_with?('profile="') && str[9...-1].split.include?('https://www.w3.org/ns/activitystreams')
|
||||
end
|
||||
end
|
||||
|
||||
|
@ -206,8 +218,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
|
||||
|
|
|
@ -153,7 +153,8 @@ class ActivityPub::Activity
|
|||
def fetch_remote_original_status
|
||||
if object_uri.start_with?('http')
|
||||
return if ActivityPub::TagManager.instance.local_uri?(object_uri)
|
||||
ActivityPub::FetchRemoteStatusService.new.call(object_uri, id: true, on_behalf_of: @account.followers.local.first, request_id: @options[:request_id])
|
||||
|
||||
ActivityPub::FetchRemoteStatusService.new.call(object_uri, on_behalf_of: @account.followers.local.first, request_id: @options[:request_id])
|
||||
elsif @object['url'].present?
|
||||
::FetchRemoteStatusService.new.call(@object['url'], request_id: @options[:request_id])
|
||||
end
|
||||
|
|
|
@ -19,7 +19,7 @@ class ActivityPub::LinkedDataSignature
|
|||
return unless type == 'RsaSignature2017'
|
||||
|
||||
creator = ActivityPub::TagManager.instance.uri_to_actor(creator_uri)
|
||||
creator = ActivityPub::FetchRemoteKeyService.new.call(creator_uri, id: false) if creator&.public_key.blank?
|
||||
creator = ActivityPub::FetchRemoteKeyService.new.call(creator_uri) if creator&.public_key.blank?
|
||||
|
||||
return if creator.nil?
|
||||
|
||||
|
|
|
@ -4,12 +4,32 @@ module ApplicationExtension
|
|||
extend ActiveSupport::Concern
|
||||
|
||||
included do
|
||||
include Redisable
|
||||
|
||||
validates :name, length: { maximum: 60 }
|
||||
validates :website, url: true, length: { maximum: 2_000 }, if: :website?
|
||||
validates :redirect_uri, length: { maximum: 2_000 }
|
||||
|
||||
# The relationship used between Applications and AccessTokens is using
|
||||
# dependent: delete_all, which means the ActiveRecord callback in
|
||||
# AccessTokenExtension is not run, so instead we manually announce to
|
||||
# streaming that these tokens are being deleted.
|
||||
before_destroy :push_to_streaming_api, prepend: true
|
||||
end
|
||||
|
||||
def confirmation_redirect_uri
|
||||
redirect_uri.lines.first.strip
|
||||
end
|
||||
|
||||
def push_to_streaming_api
|
||||
# TODO: #28793 Combine into a single topic
|
||||
payload = Oj.dump(event: :kill)
|
||||
access_tokens.in_batches do |tokens|
|
||||
redis.pipelined do |pipeline|
|
||||
tokens.ids.each do |id|
|
||||
pipeline.publish("timeline:access_token:#{id}", payload)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
@ -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))
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -112,7 +112,7 @@ class Account < ApplicationRecord
|
|||
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 :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 :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')) }
|
||||
|
|
|
@ -19,17 +19,18 @@ module Omniauthable
|
|||
end
|
||||
|
||||
class_methods do
|
||||
def find_for_oauth(auth, signed_in_resource = nil)
|
||||
def find_for_omniauth(auth, signed_in_resource = nil)
|
||||
# EOLE-SSO Patch
|
||||
auth.uid = (auth.uid[0][:uid] || auth.uid[0][:user]) if auth.uid.is_a? Hashie::Array
|
||||
identity = Identity.find_for_oauth(auth)
|
||||
identity = Identity.find_for_omniauth(auth)
|
||||
|
||||
# If a signed_in_resource is provided it always overrides the existing user
|
||||
# to prevent the identity being locked with accidentally created accounts.
|
||||
# Note that this may leave zombie accounts (with no associated identity) which
|
||||
# can be cleaned up at a later date.
|
||||
user = signed_in_resource || identity.user
|
||||
user ||= create_for_oauth(auth)
|
||||
user ||= reattach_for_auth(auth)
|
||||
user ||= create_for_auth(auth)
|
||||
|
||||
if identity.user.nil?
|
||||
identity.user = user
|
||||
|
@ -39,19 +40,35 @@ module Omniauthable
|
|||
user
|
||||
end
|
||||
|
||||
def create_for_oauth(auth)
|
||||
# Check if the user exists with provided email. If no email was provided,
|
||||
private
|
||||
|
||||
def reattach_for_auth(auth)
|
||||
# If allowed, check if a user exists with the provided email address,
|
||||
# and return it if they does not have an associated identity with the
|
||||
# current authentication provider.
|
||||
|
||||
# This can be used to provide a choice of alternative auth providers
|
||||
# or provide smooth gradual transition between multiple auth providers,
|
||||
# but this is discouraged because any insecure provider will put *all*
|
||||
# local users at risk, regardless of which provider they registered with.
|
||||
|
||||
return unless ENV['ALLOW_UNSAFE_AUTH_PROVIDER_REATTACH'] == 'true'
|
||||
|
||||
email, email_is_verified = email_from_auth(auth)
|
||||
return unless email_is_verified
|
||||
|
||||
user = User.find_by(email: email)
|
||||
return if user.nil? || Identity.exists?(provider: auth.provider, user_id: user.id)
|
||||
|
||||
user
|
||||
end
|
||||
|
||||
def create_for_auth(auth)
|
||||
# Create a user for the given auth params. If no email was provided,
|
||||
# we assign a temporary email and ask the user to verify it on
|
||||
# the next step via Auth::SetupController.show
|
||||
|
||||
strategy = Devise.omniauth_configs[auth.provider.to_sym].strategy
|
||||
assume_verified = strategy&.security&.assume_email_is_verified
|
||||
email_is_verified = auth.info.verified || auth.info.verified_email || auth.info.email_verified || assume_verified
|
||||
email = auth.info.verified_email || auth.info.email
|
||||
|
||||
user = User.find_by(email: email) if email_is_verified
|
||||
|
||||
return user unless user.nil?
|
||||
email, email_is_verified = email_from_auth(auth)
|
||||
|
||||
user = User.new(user_params_from_auth(email, auth))
|
||||
|
||||
|
@ -68,7 +85,14 @@ module Omniauthable
|
|||
user
|
||||
end
|
||||
|
||||
private
|
||||
def email_from_auth(auth)
|
||||
strategy = Devise.omniauth_configs[auth.provider.to_sym].strategy
|
||||
assume_verified = strategy&.security&.assume_email_is_verified
|
||||
email_is_verified = auth.info.verified || auth.info.verified_email || auth.info.email_verified || assume_verified
|
||||
email = auth.info.verified_email || auth.info.email
|
||||
|
||||
[email, email_is_verified]
|
||||
end
|
||||
|
||||
def user_params_from_auth(email, auth)
|
||||
{
|
||||
|
|
|
@ -16,7 +16,7 @@ class Identity < ApplicationRecord
|
|||
validates :uid, presence: true, uniqueness: { scope: :provider }
|
||||
validates :provider, presence: true
|
||||
|
||||
def self.find_for_oauth(auth)
|
||||
def self.find_for_omniauth(auth)
|
||||
find_or_create_by(uid: auth.uid, provider: auth.provider)
|
||||
end
|
||||
end
|
||||
|
|
|
@ -399,6 +399,16 @@ class User < ApplicationRecord
|
|||
Doorkeeper::AccessToken.by_resource_owner(self).in_batches do |batch|
|
||||
batch.update_all(revoked_at: Time.now.utc)
|
||||
Web::PushSubscription.where(access_token_id: batch).delete_all
|
||||
|
||||
# Revoke each access token for the Streaming API, since `update_all``
|
||||
# doesn't trigger ActiveRecord Callbacks:
|
||||
# TODO: #28793 Combine into a single topic
|
||||
payload = Oj.dump(event: :kill)
|
||||
redis.pipelined do |pipeline|
|
||||
batch.ids.each do |id|
|
||||
pipeline.publish("timeline:access_token:#{id}", payload)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
class ActivityPub::FetchRemoteAccountService < ActivityPub::FetchRemoteActorService
|
||||
# Does a WebFinger roundtrip on each call, unless `only_key` is true
|
||||
def call(uri, id: true, prefetched_body: nil, break_on_redirect: false, only_key: false, suppress_errors: true, request_id: nil)
|
||||
def call(uri, prefetched_body: nil, break_on_redirect: false, only_key: false, suppress_errors: true, request_id: nil)
|
||||
actor = super
|
||||
return actor if actor.nil? || actor.is_a?(Account)
|
||||
|
||||
|
|
|
@ -10,15 +10,15 @@ class ActivityPub::FetchRemoteActorService < BaseService
|
|||
SUPPORTED_TYPES = %w(Application Group Organization Person Service).freeze
|
||||
|
||||
# Does a WebFinger roundtrip on each call, unless `only_key` is true
|
||||
def call(uri, id: true, prefetched_body: nil, break_on_redirect: false, only_key: false, suppress_errors: true, request_id: nil)
|
||||
def call(uri, prefetched_body: nil, break_on_redirect: false, only_key: false, suppress_errors: true, request_id: nil)
|
||||
return if domain_not_allowed?(uri)
|
||||
return ActivityPub::TagManager.instance.uri_to_actor(uri) if ActivityPub::TagManager.instance.local_uri?(uri)
|
||||
|
||||
@json = begin
|
||||
if prefetched_body.nil?
|
||||
fetch_resource(uri, id)
|
||||
fetch_resource(uri, true)
|
||||
else
|
||||
body_to_json(prefetched_body, compare_id: id ? uri : nil)
|
||||
body_to_json(prefetched_body, compare_id: uri)
|
||||
end
|
||||
rescue Oj::ParseError
|
||||
raise Error, "Error parsing JSON-LD document #{uri}"
|
||||
|
|
|
@ -6,23 +6,10 @@ class ActivityPub::FetchRemoteKeyService < BaseService
|
|||
class Error < StandardError; end
|
||||
|
||||
# Returns actor that owns the key
|
||||
def call(uri, id: true, prefetched_body: nil, suppress_errors: true)
|
||||
def call(uri, suppress_errors: true)
|
||||
raise Error, 'No key URI given' if uri.blank?
|
||||
|
||||
if prefetched_body.nil?
|
||||
if id
|
||||
@json = fetch_resource_without_id_validation(uri)
|
||||
if actor_type?
|
||||
@json = fetch_resource(@json['id'], true)
|
||||
elsif uri != @json['id']
|
||||
raise Error, "Fetched URI #{uri} has wrong id #{@json['id']}"
|
||||
end
|
||||
else
|
||||
@json = fetch_resource(uri, id)
|
||||
end
|
||||
else
|
||||
@json = body_to_json(prefetched_body, compare_id: id ? uri : nil)
|
||||
end
|
||||
@json = fetch_resource(uri, false)
|
||||
|
||||
raise Error, "Unable to fetch key JSON at #{uri}" if @json.nil?
|
||||
raise Error, "Unsupported JSON-LD context for document #{uri}" unless supported_context?(@json)
|
||||
|
|
|
@ -7,13 +7,13 @@ class ActivityPub::FetchRemoteStatusService < BaseService
|
|||
DISCOVERIES_PER_REQUEST = 1000
|
||||
|
||||
# Should be called when uri has already been checked for locality
|
||||
def call(uri, id: true, prefetched_body: nil, on_behalf_of: nil, expected_actor_uri: nil, request_id: nil)
|
||||
def call(uri, prefetched_body: nil, on_behalf_of: nil, expected_actor_uri: nil, request_id: nil)
|
||||
@request_id = request_id || "#{Time.now.utc.to_i}-status-#{uri}"
|
||||
@json = begin
|
||||
if prefetched_body.nil?
|
||||
fetch_resource(uri, id, on_behalf_of)
|
||||
fetch_resource(uri, true, on_behalf_of)
|
||||
else
|
||||
body_to_json(prefetched_body, compare_id: id ? uri : nil)
|
||||
body_to_json(prefetched_body, compare_id: uri)
|
||||
end
|
||||
end
|
||||
|
||||
|
@ -63,7 +63,7 @@ class ActivityPub::FetchRemoteStatusService < BaseService
|
|||
|
||||
def account_from_uri(uri)
|
||||
actor = ActivityPub::TagManager.instance.uri_to_resource(uri, Account)
|
||||
actor = ActivityPub::FetchRemoteAccountService.new.call(uri, id: true, request_id: @request_id) if actor.nil? || actor.possibly_stale?
|
||||
actor = ActivityPub::FetchRemoteAccountService.new.call(uri, request_id: @request_id) if actor.nil? || actor.possibly_stale?
|
||||
actor
|
||||
end
|
||||
|
||||
|
|
|
@ -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
|
||||
|
||||
|
@ -36,7 +36,21 @@ class ActivityPub::FetchRepliesService < BaseService
|
|||
return collection_or_uri if collection_or_uri.is_a?(Hash)
|
||||
return unless @allow_synchronous_requests
|
||||
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
|
||||
|
||||
def filtered_replies
|
||||
|
|
|
@ -274,7 +274,7 @@ class ActivityPub::ProcessAccountService < BaseService
|
|||
|
||||
def moved_account
|
||||
account = ActivityPub::TagManager.instance.uri_to_resource(@json['movedTo'], Account)
|
||||
account ||= ActivityPub::FetchRemoteAccountService.new.call(@json['movedTo'], id: true, break_on_redirect: true, request_id: @options[:request_id])
|
||||
account ||= ActivityPub::FetchRemoteAccountService.new.call(@json['movedTo'], break_on_redirect: true, request_id: @options[:request_id])
|
||||
account
|
||||
end
|
||||
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -43,11 +43,19 @@ class FetchResourceService < BaseService
|
|||
@response_code = response.code
|
||||
return nil if response.code != 200
|
||||
|
||||
if ['application/activity+json', 'application/ld+json'].include?(response.mime_type)
|
||||
if valid_activitypub_content_type?(response)
|
||||
body = response.body_with_limit
|
||||
json = body_to_json(body)
|
||||
|
||||
[json['id'], { prefetched_body: body, id: true }] if supported_context?(json) && (equals_or_includes_any?(json['type'], ActivityPub::FetchRemoteActorService::SUPPORTED_TYPES) || expected_type?(json))
|
||||
return unless supported_context?(json) && (equals_or_includes_any?(json['type'], ActivityPub::FetchRemoteActorService::SUPPORTED_TYPES) || expected_type?(json))
|
||||
|
||||
if json['id'] != @url
|
||||
return if terminal
|
||||
|
||||
return process(json['id'], terminal: true)
|
||||
end
|
||||
|
||||
[@url, { prefetched_body: body }]
|
||||
elsif !terminal
|
||||
link_header = response['Link'] && parse_link_header(response)
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -45,11 +45,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)
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -19,9 +19,14 @@ Doorkeeper.configure do
|
|||
user unless user&.otp_required_for_login?
|
||||
end
|
||||
|
||||
# If you want to restrict access to the web interface for adding oauth authorized applications, you need to declare the block below.
|
||||
# Doorkeeper provides some administrative interfaces for managing OAuth
|
||||
# Applications, allowing creation, edit, and deletion of applications from the
|
||||
# server. At present, these administrative routes are not integrated into
|
||||
# Mastodon, and as such, we've disabled them by always return a 403 forbidden
|
||||
# response for them. This does not affect the ability for users to manage
|
||||
# their own OAuth Applications.
|
||||
admin_authenticator do
|
||||
current_user&.admin? || redirect_to(new_user_session_url)
|
||||
head 403
|
||||
end
|
||||
|
||||
# Authorization Code expiration time (default 10 minutes).
|
||||
|
|
|
@ -12,6 +12,7 @@ en:
|
|||
last_attempt: You have one more attempt before your account is locked.
|
||||
locked: Your account is locked.
|
||||
not_found_in_database: Invalid %{authentication_keys} or password.
|
||||
omniauth_user_creation_failure: Error creating an account for this identity.
|
||||
pending: Your account is still under review.
|
||||
timeout: Your session expired. Please sign in again to continue.
|
||||
unauthenticated: You need to sign in or sign up before continuing.
|
||||
|
|
|
@ -1684,6 +1684,7 @@ en:
|
|||
follow_limit_reached: You cannot follow more than %{limit} people
|
||||
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:
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
require 'sidekiq_unique_jobs/web'
|
||||
require 'sidekiq_unique_jobs/web' if ENV['ENABLE_SIDEKIQ_UNIQUE_JOBS_UI'] == true
|
||||
require 'sidekiq-scheduler/web'
|
||||
|
||||
Rails.application.routes.draw do
|
||||
|
|
|
@ -56,7 +56,7 @@ services:
|
|||
|
||||
web:
|
||||
build: .
|
||||
image: ghcr.io/mastodon/mastodon:v4.1.11
|
||||
image: ghcr.io/mastodon/mastodon:v4.1.15
|
||||
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.1.11
|
||||
image: ghcr.io/mastodon/mastodon:v4.1.15
|
||||
restart: always
|
||||
env_file: .env.production
|
||||
command: node ./streaming
|
||||
|
@ -95,7 +95,7 @@ services:
|
|||
|
||||
sidekiq:
|
||||
build: .
|
||||
image: ghcr.io/mastodon/mastodon:v4.1.11
|
||||
image: ghcr.io/mastodon/mastodon:v4.1.15
|
||||
restart: always
|
||||
env_file: .env.production
|
||||
command: bundle exec sidekiq
|
||||
|
|
|
@ -13,7 +13,7 @@ module Mastodon
|
|||
end
|
||||
|
||||
def patch
|
||||
11
|
||||
15
|
||||
end
|
||||
|
||||
def flags
|
||||
|
|
|
@ -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
|
||||
|
|
11
lib/tasks/sidekiq_unique_jobs.rake
Normal file
11
lib/tasks/sidekiq_unique_jobs.rake
Normal file
|
@ -0,0 +1,11 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
namespace :sidekiq_unique_jobs do
|
||||
task delete_all_locks: :environment do
|
||||
digests = SidekiqUniqueJobs::Digests.new
|
||||
digests.delete_by_pattern('*', count: digests.count)
|
||||
|
||||
expiring_digests = SidekiqUniqueJobs::ExpiringDigests.new
|
||||
expiring_digests.delete_by_pattern('*', count: expiring_digests.count)
|
||||
end
|
||||
end
|
|
@ -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
|
||||
|
|
|
@ -262,7 +262,27 @@ RSpec.describe Auth::SessionsController, type: :controller do
|
|||
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
|
||||
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
|
||||
|
|
|
@ -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
|
|
@ -56,15 +56,15 @@ describe JsonLdHelper do
|
|||
describe '#fetch_resource' do
|
||||
context 'when the second argument is false' do
|
||||
it 'returns resource even if the retrieved ID and the given URI does not match' do
|
||||
stub_request(:get, 'https://bob.test/').to_return body: '{"id": "https://alice.test/"}'
|
||||
stub_request(:get, 'https://alice.test/').to_return body: '{"id": "https://alice.test/"}'
|
||||
stub_request(:get, 'https://bob.test/').to_return(body: '{"id": "https://alice.test/"}', headers: { 'Content-Type': 'application/activity+json' })
|
||||
stub_request(:get, 'https://alice.test/').to_return(body: '{"id": "https://alice.test/"}', headers: { 'Content-Type': 'application/activity+json' })
|
||||
|
||||
expect(fetch_resource('https://bob.test/', false)).to eq({ 'id' => 'https://alice.test/' })
|
||||
end
|
||||
|
||||
it 'returns nil if the object identified by the given URI and the object identified by the retrieved ID does not match' do
|
||||
stub_request(:get, 'https://mallory.test/').to_return body: '{"id": "https://marvin.test/"}'
|
||||
stub_request(:get, 'https://marvin.test/').to_return body: '{"id": "https://alice.test/"}'
|
||||
stub_request(:get, 'https://mallory.test/').to_return(body: '{"id": "https://marvin.test/"}', headers: { 'Content-Type': 'application/activity+json' })
|
||||
stub_request(:get, 'https://marvin.test/').to_return(body: '{"id": "https://alice.test/"}', headers: { 'Content-Type': 'application/activity+json' })
|
||||
|
||||
expect(fetch_resource('https://mallory.test/', false)).to eq nil
|
||||
end
|
||||
|
@ -72,7 +72,7 @@ describe JsonLdHelper do
|
|||
|
||||
context 'when the second argument is true' do
|
||||
it 'returns nil if the retrieved ID and the given URI does not match' do
|
||||
stub_request(:get, 'https://mallory.test/').to_return body: '{"id": "https://alice.test/"}'
|
||||
stub_request(:get, 'https://mallory.test/').to_return(body: '{"id": "https://alice.test/"}', headers: { 'Content-Type': 'application/activity+json' })
|
||||
expect(fetch_resource('https://mallory.test/', true)).to eq nil
|
||||
end
|
||||
end
|
||||
|
@ -80,12 +80,12 @@ describe JsonLdHelper do
|
|||
|
||||
describe '#fetch_resource_without_id_validation' do
|
||||
it 'returns nil if the status code is not 200' do
|
||||
stub_request(:get, 'https://host.test/').to_return status: 400, body: '{}'
|
||||
stub_request(:get, 'https://host.test/').to_return(status: 400, body: '{}', headers: { 'Content-Type': 'application/activity+json' })
|
||||
expect(fetch_resource_without_id_validation('https://host.test/')).to eq nil
|
||||
end
|
||||
|
||||
it 'returns hash' do
|
||||
stub_request(:get, 'https://host.test/').to_return status: 200, body: '{}'
|
||||
stub_request(:get, 'https://host.test/').to_return(status: 200, body: '{}', headers: { 'Content-Type': 'application/activity+json' })
|
||||
expect(fetch_resource_without_id_validation('https://host.test/')).to eq({})
|
||||
end
|
||||
end
|
||||
|
|
|
@ -33,7 +33,7 @@ RSpec.describe ActivityPub::Activity::Announce do
|
|||
context 'when sender is followed by a local account' do
|
||||
before do
|
||||
Fabricate(:account).follow!(sender)
|
||||
stub_request(:get, 'https://example.com/actor/hello-world').to_return(body: Oj.dump(unknown_object_json))
|
||||
stub_request(:get, 'https://example.com/actor/hello-world').to_return(body: Oj.dump(unknown_object_json), headers: { 'Content-Type': 'application/activity+json' })
|
||||
subject.perform
|
||||
end
|
||||
|
||||
|
@ -118,7 +118,7 @@ RSpec.describe ActivityPub::Activity::Announce do
|
|||
subject { described_class.new(json, sender, relayed_through_actor: relay_account) }
|
||||
|
||||
before do
|
||||
stub_request(:get, 'https://example.com/actor/hello-world').to_return(body: Oj.dump(unknown_object_json))
|
||||
stub_request(:get, 'https://example.com/actor/hello-world').to_return(body: Oj.dump(unknown_object_json), headers: { 'Content-Type': 'application/activity+json' })
|
||||
end
|
||||
|
||||
context 'and the relay is enabled' do
|
||||
|
|
|
@ -58,7 +58,7 @@ RSpec.describe ActivityPub::LinkedDataSignature do
|
|||
|
||||
allow(ActivityPub::FetchRemoteKeyService).to receive(:new).and_return(service_stub)
|
||||
|
||||
allow(service_stub).to receive(:call).with('http://example.com/alice', id: false) do
|
||||
allow(service_stub).to receive(:call).with('http://example.com/alice') do
|
||||
sender.update!(public_key: old_key)
|
||||
sender
|
||||
end
|
||||
|
@ -66,7 +66,7 @@ RSpec.describe ActivityPub::LinkedDataSignature do
|
|||
|
||||
it 'fetches key and returns creator' do
|
||||
expect(subject.verify_actor!).to eq sender
|
||||
expect(service_stub).to have_received(:call).with('http://example.com/alice', id: false).once
|
||||
expect(service_stub).to have_received(:call).with('http://example.com/alice').once
|
||||
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_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
|
||||
|
|
|
@ -1,16 +1,16 @@
|
|||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Identity, type: :model do
|
||||
describe '.find_for_oauth' do
|
||||
describe '.find_for_omniauth' do
|
||||
let(:auth) { Fabricate(:identity, user: Fabricate(:user)) }
|
||||
|
||||
it 'calls .find_or_create_by' do
|
||||
expect(described_class).to receive(:find_or_create_by).with(uid: auth.uid, provider: auth.provider)
|
||||
described_class.find_for_oauth(auth)
|
||||
described_class.find_for_omniauth(auth)
|
||||
end
|
||||
|
||||
it 'returns an instance of Identity' do
|
||||
expect(described_class.find_for_oauth(auth)).to be_instance_of Identity
|
||||
expect(described_class.find_for_omniauth(auth)).to be_instance_of Identity
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
@ -439,7 +439,10 @@ RSpec.describe User, type: :model do
|
|||
let!(:access_token) { Fabricate(:access_token, resource_owner_id: user.id) }
|
||||
let!(:web_push_subscription) { Fabricate(:web_push_subscription, access_token: access_token) }
|
||||
|
||||
let(:redis_pipeline_stub) { instance_double(Redis::Namespace, publish: nil) }
|
||||
|
||||
before do
|
||||
allow(redis).to receive(:pipelined).and_yield(redis_pipeline_stub)
|
||||
user.reset_password!
|
||||
end
|
||||
|
||||
|
@ -455,6 +458,10 @@ RSpec.describe User, type: :model do
|
|||
expect(Doorkeeper::AccessToken.active_for(user).count).to eq 0
|
||||
end
|
||||
|
||||
it 'revokes streaming access for all access tokens' do
|
||||
expect(redis_pipeline_stub).to have_received(:publish).with("timeline:access_token:#{access_token.id}", Oj.dump(event: :kill)).once
|
||||
end
|
||||
|
||||
it 'removes push subscriptions' do
|
||||
expect(Web::PushSubscription.where(user: user).or(Web::PushSubscription.where(access_token: access_token)).count).to eq 0
|
||||
end
|
||||
|
|
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
|
83
spec/requests/disabled_oauth_endpoints_spec.rb
Normal file
83
spec/requests/disabled_oauth_endpoints_spec.rb
Normal file
|
@ -0,0 +1,83 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
require 'rails_helper'
|
||||
|
||||
describe 'Disabled OAuth routes' do
|
||||
# These routes are disabled via the doorkeeper configuration for
|
||||
# `admin_authenticator`, as these routes should only be accessible by server
|
||||
# administrators. For now, these routes are not properly designed and
|
||||
# integrated into Mastodon, so we're disabling them completely
|
||||
describe 'GET /oauth/applications' do
|
||||
it 'returns 403 forbidden' do
|
||||
get oauth_applications_path
|
||||
|
||||
expect(response).to have_http_status(403)
|
||||
end
|
||||
end
|
||||
|
||||
describe 'POST /oauth/applications' do
|
||||
it 'returns 403 forbidden' do
|
||||
post oauth_applications_path
|
||||
|
||||
expect(response).to have_http_status(403)
|
||||
end
|
||||
end
|
||||
|
||||
describe 'GET /oauth/applications/new' do
|
||||
it 'returns 403 forbidden' do
|
||||
get new_oauth_application_path
|
||||
|
||||
expect(response).to have_http_status(403)
|
||||
end
|
||||
end
|
||||
|
||||
describe 'GET /oauth/applications/:id' do
|
||||
let(:application) { Fabricate(:application, scopes: 'read') }
|
||||
|
||||
it 'returns 403 forbidden' do
|
||||
get oauth_application_path(application)
|
||||
|
||||
expect(response).to have_http_status(403)
|
||||
end
|
||||
end
|
||||
|
||||
describe 'PATCH /oauth/applications/:id' do
|
||||
let(:application) { Fabricate(:application, scopes: 'read') }
|
||||
|
||||
it 'returns 403 forbidden' do
|
||||
patch oauth_application_path(application)
|
||||
|
||||
expect(response).to have_http_status(403)
|
||||
end
|
||||
end
|
||||
|
||||
describe 'PUT /oauth/applications/:id' do
|
||||
let(:application) { Fabricate(:application, scopes: 'read') }
|
||||
|
||||
it 'returns 403 forbidden' do
|
||||
put oauth_application_path(application)
|
||||
|
||||
expect(response).to have_http_status(403)
|
||||
end
|
||||
end
|
||||
|
||||
describe 'DELETE /oauth/applications/:id' do
|
||||
let(:application) { Fabricate(:application, scopes: 'read') }
|
||||
|
||||
it 'returns 403 forbidden' do
|
||||
delete oauth_application_path(application)
|
||||
|
||||
expect(response).to have_http_status(403)
|
||||
end
|
||||
end
|
||||
|
||||
describe 'GET /oauth/applications/:id/edit' do
|
||||
let(:application) { Fabricate(:application, scopes: 'read') }
|
||||
|
||||
it 'returns 403 forbidden' do
|
||||
get edit_oauth_application_path(application)
|
||||
|
||||
expect(response).to have_http_status(403)
|
||||
end
|
||||
end
|
||||
end
|
143
spec/requests/omniauth_callbacks_spec.rb
Normal file
143
spec/requests/omniauth_callbacks_spec.rb
Normal file
|
@ -0,0 +1,143 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
require 'rails_helper'
|
||||
|
||||
describe 'OmniAuth callbacks' do
|
||||
shared_examples 'omniauth provider callbacks' do |provider|
|
||||
subject { post send :"user_#{provider}_omniauth_callback_path" }
|
||||
|
||||
context 'with full information in response' do
|
||||
before do
|
||||
mock_omniauth(provider, {
|
||||
provider: provider.to_s,
|
||||
uid: '123',
|
||||
info: {
|
||||
verified: 'true',
|
||||
email: 'user@host.example',
|
||||
},
|
||||
})
|
||||
end
|
||||
|
||||
context 'without a matching user' do
|
||||
it 'creates a user and an identity and redirects to root path' do
|
||||
expect { subject }
|
||||
.to change(User, :count)
|
||||
.by(1)
|
||||
.and change(Identity, :count)
|
||||
.by(1)
|
||||
.and change(LoginActivity, :count)
|
||||
.by(1)
|
||||
|
||||
expect(User.last.email).to eq('user@host.example')
|
||||
expect(Identity.find_by(user: User.last).uid).to eq('123')
|
||||
expect(response).to redirect_to(root_path)
|
||||
end
|
||||
end
|
||||
|
||||
context 'with a matching user and no matching identity' do
|
||||
before do
|
||||
Fabricate(:user, email: 'user@host.example')
|
||||
end
|
||||
|
||||
context 'when ALLOW_UNSAFE_AUTH_PROVIDER_REATTACH is set to true' do
|
||||
around do |example|
|
||||
ClimateControl.modify ALLOW_UNSAFE_AUTH_PROVIDER_REATTACH: 'true' do
|
||||
example.run
|
||||
end
|
||||
end
|
||||
|
||||
it 'matches the existing user, creates an identity, and redirects to root path' do
|
||||
expect { subject }
|
||||
.to not_change(User, :count)
|
||||
.and change(Identity, :count)
|
||||
.by(1)
|
||||
.and change(LoginActivity, :count)
|
||||
.by(1)
|
||||
|
||||
expect(Identity.find_by(user: User.last).uid).to eq('123')
|
||||
expect(response).to redirect_to(root_path)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when ALLOW_UNSAFE_AUTH_PROVIDER_REATTACH is not set to true' do
|
||||
it 'does not match the existing user or create an identity, and redirects to login page' do
|
||||
expect { subject }
|
||||
.to not_change(User, :count)
|
||||
.and not_change(Identity, :count)
|
||||
.and not_change(LoginActivity, :count)
|
||||
|
||||
expect(response).to redirect_to(new_user_session_url)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context 'with a matching user and a matching identity' do
|
||||
before do
|
||||
user = Fabricate(:user, email: 'user@host.example')
|
||||
Fabricate(:identity, user: user, uid: '123', provider: provider)
|
||||
end
|
||||
|
||||
it 'matches the existing records and redirects to root path' do
|
||||
expect { subject }
|
||||
.to not_change(User, :count)
|
||||
.and not_change(Identity, :count)
|
||||
.and change(LoginActivity, :count)
|
||||
.by(1)
|
||||
|
||||
expect(response).to redirect_to(root_path)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context 'with a response missing email address' do
|
||||
before do
|
||||
mock_omniauth(provider, {
|
||||
provider: provider.to_s,
|
||||
uid: '123',
|
||||
info: {
|
||||
verified: 'true',
|
||||
},
|
||||
})
|
||||
end
|
||||
|
||||
it 'redirects to the auth setup page' do
|
||||
expect { subject }
|
||||
.to change(User, :count)
|
||||
.by(1)
|
||||
.and change(Identity, :count)
|
||||
.by(1)
|
||||
.and change(LoginActivity, :count)
|
||||
.by(1)
|
||||
|
||||
expect(response).to redirect_to(auth_setup_path(missing_email: '1'))
|
||||
end
|
||||
end
|
||||
|
||||
context 'when a user cannot be built' do
|
||||
before do
|
||||
allow(User).to receive(:find_for_omniauth).and_return(User.new)
|
||||
end
|
||||
|
||||
it 'redirects to the new user signup page' do
|
||||
expect { subject }
|
||||
.to not_change(User, :count)
|
||||
.and not_change(Identity, :count)
|
||||
.and not_change(LoginActivity, :count)
|
||||
|
||||
expect(response).to redirect_to(new_user_registration_url)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe '#openid_connect', if: ENV['OIDC_ENABLED'] == 'true' && ENV['OIDC_SCOPE'].present? do
|
||||
include_examples 'omniauth provider callbacks', :openid_connect
|
||||
end
|
||||
|
||||
describe '#cas', if: ENV['CAS_ENABLED'] == 'true' do
|
||||
include_examples 'omniauth provider callbacks', :cas
|
||||
end
|
||||
|
||||
describe '#saml', if: ENV['SAML_ENABLED'] == 'true' do
|
||||
include_examples 'omniauth provider callbacks', :saml
|
||||
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
|
|
@ -60,10 +60,10 @@ RSpec.describe ActivityPub::FetchFeaturedCollectionService, type: :service do
|
|||
|
||||
shared_examples 'sets pinned posts' do
|
||||
before do
|
||||
stub_request(:get, 'https://example.com/account/pinned/1').to_return(status: 200, body: Oj.dump(status_json_1))
|
||||
stub_request(:get, 'https://example.com/account/pinned/2').to_return(status: 200, body: Oj.dump(status_json_2))
|
||||
stub_request(:get, 'https://example.com/account/pinned/1').to_return(status: 200, body: Oj.dump(status_json_1), headers: { 'Content-Type': 'application/activity+json' })
|
||||
stub_request(:get, 'https://example.com/account/pinned/2').to_return(status: 200, body: Oj.dump(status_json_2), headers: { 'Content-Type': 'application/activity+json' })
|
||||
stub_request(:get, 'https://example.com/account/pinned/3').to_return(status: 404)
|
||||
stub_request(:get, 'https://example.com/account/pinned/4').to_return(status: 200, body: Oj.dump(status_json_4))
|
||||
stub_request(:get, 'https://example.com/account/pinned/4').to_return(status: 200, body: Oj.dump(status_json_4), headers: { 'Content-Type': 'application/activity+json' })
|
||||
|
||||
subject.call(actor, note: true, hashtag: false)
|
||||
end
|
||||
|
@ -76,7 +76,7 @@ RSpec.describe ActivityPub::FetchFeaturedCollectionService, type: :service do
|
|||
describe '#call' do
|
||||
context 'when the endpoint is a Collection' do
|
||||
before do
|
||||
stub_request(:get, actor.featured_collection_url).to_return(status: 200, body: Oj.dump(payload))
|
||||
stub_request(:get, actor.featured_collection_url).to_return(status: 200, body: Oj.dump(payload), headers: { 'Content-Type': 'application/activity+json' })
|
||||
end
|
||||
|
||||
it_behaves_like 'sets pinned posts'
|
||||
|
@ -93,10 +93,25 @@ RSpec.describe ActivityPub::FetchFeaturedCollectionService, type: :service do
|
|||
end
|
||||
|
||||
before do
|
||||
stub_request(:get, actor.featured_collection_url).to_return(status: 200, body: Oj.dump(payload))
|
||||
stub_request(:get, actor.featured_collection_url).to_return(status: 200, body: Oj.dump(payload), headers: { 'Content-Type': 'application/activity+json' })
|
||||
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/4' }
|
||||
|
||||
before do
|
||||
stub_request(:get, 'https://example.com/account/pinned/4').to_return(status: 200, body: Oj.dump(status_json_4), headers: { 'Content-Type': 'application/activity+json' })
|
||||
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
|
||||
|
||||
context 'when the endpoint is a paginated Collection' do
|
||||
|
@ -114,10 +129,25 @@ RSpec.describe ActivityPub::FetchFeaturedCollectionService, type: :service do
|
|||
end
|
||||
|
||||
before do
|
||||
stub_request(:get, actor.featured_collection_url).to_return(status: 200, body: Oj.dump(payload))
|
||||
stub_request(:get, actor.featured_collection_url).to_return(status: 200, body: Oj.dump(payload), headers: { 'Content-Type': 'application/activity+json' })
|
||||
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/4' }
|
||||
|
||||
before do
|
||||
stub_request(:get, 'https://example.com/account/pinned/4').to_return(status: 200, body: Oj.dump(status_json_4), headers: { 'Content-Type': 'application/activity+json' })
|
||||
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
|
||||
|
|
|
@ -36,7 +36,7 @@ RSpec.describe ActivityPub::FetchFeaturedTagsCollectionService, type: :service d
|
|||
describe '#call' do
|
||||
context 'when the endpoint is a Collection' do
|
||||
before do
|
||||
stub_request(:get, collection_url).to_return(status: 200, body: Oj.dump(payload))
|
||||
stub_request(:get, collection_url).to_return(status: 200, body: Oj.dump(payload), headers: { 'Content-Type': 'application/activity+json' })
|
||||
end
|
||||
|
||||
it_behaves_like 'sets featured tags'
|
||||
|
@ -44,7 +44,7 @@ RSpec.describe ActivityPub::FetchFeaturedTagsCollectionService, type: :service d
|
|||
|
||||
context 'when the account already has featured tags' do
|
||||
before do
|
||||
stub_request(:get, collection_url).to_return(status: 200, body: Oj.dump(payload))
|
||||
stub_request(:get, collection_url).to_return(status: 200, body: Oj.dump(payload), headers: { 'Content-Type': 'application/activity+json' })
|
||||
|
||||
actor.featured_tags.create!(name: 'FoO')
|
||||
actor.featured_tags.create!(name: 'baz')
|
||||
|
@ -65,7 +65,7 @@ RSpec.describe ActivityPub::FetchFeaturedTagsCollectionService, type: :service d
|
|||
end
|
||||
|
||||
before do
|
||||
stub_request(:get, collection_url).to_return(status: 200, body: Oj.dump(payload))
|
||||
stub_request(:get, collection_url).to_return(status: 200, body: Oj.dump(payload), headers: { 'Content-Type': 'application/activity+json' })
|
||||
end
|
||||
|
||||
it_behaves_like 'sets featured tags'
|
||||
|
@ -86,7 +86,7 @@ RSpec.describe ActivityPub::FetchFeaturedTagsCollectionService, type: :service d
|
|||
end
|
||||
|
||||
before do
|
||||
stub_request(:get, collection_url).to_return(status: 200, body: Oj.dump(payload))
|
||||
stub_request(:get, collection_url).to_return(status: 200, body: Oj.dump(payload), headers: { 'Content-Type': 'application/activity+json' })
|
||||
end
|
||||
|
||||
it_behaves_like 'sets featured tags'
|
||||
|
|
|
@ -16,7 +16,7 @@ RSpec.describe ActivityPub::FetchRemoteAccountService, type: :service do
|
|||
end
|
||||
|
||||
describe '#call' do
|
||||
let(:account) { subject.call('https://example.com/alice', id: true) }
|
||||
let(:account) { subject.call('https://example.com/alice') }
|
||||
|
||||
shared_examples 'sets profile data' do
|
||||
it 'returns an account' do
|
||||
|
@ -42,7 +42,7 @@ RSpec.describe ActivityPub::FetchRemoteAccountService, type: :service do
|
|||
before do
|
||||
actor[:inbox] = nil
|
||||
|
||||
stub_request(:get, 'https://example.com/alice').to_return(body: Oj.dump(actor))
|
||||
stub_request(:get, 'https://example.com/alice').to_return(body: Oj.dump(actor), headers: { 'Content-Type': 'application/activity+json' })
|
||||
stub_request(:get, 'https://example.com/.well-known/webfinger?resource=acct:alice@example.com').to_return(body: Oj.dump(webfinger), headers: { 'Content-Type': 'application/jrd+json' })
|
||||
end
|
||||
|
||||
|
@ -65,7 +65,7 @@ RSpec.describe ActivityPub::FetchRemoteAccountService, type: :service do
|
|||
let!(:webfinger) { { subject: 'acct:alice@example.com', links: [{ rel: 'self', href: 'https://example.com/alice' }] } }
|
||||
|
||||
before do
|
||||
stub_request(:get, 'https://example.com/alice').to_return(body: Oj.dump(actor))
|
||||
stub_request(:get, 'https://example.com/alice').to_return(body: Oj.dump(actor), headers: { 'Content-Type': 'application/activity+json' })
|
||||
stub_request(:get, 'https://example.com/.well-known/webfinger?resource=acct:alice@example.com').to_return(body: Oj.dump(webfinger), headers: { 'Content-Type': 'application/jrd+json' })
|
||||
end
|
||||
|
||||
|
@ -91,7 +91,7 @@ RSpec.describe ActivityPub::FetchRemoteAccountService, type: :service do
|
|||
let!(:webfinger) { { subject: 'acct:alice@iscool.af', links: [{ rel: 'self', href: 'https://example.com/alice' }] } }
|
||||
|
||||
before do
|
||||
stub_request(:get, 'https://example.com/alice').to_return(body: Oj.dump(actor))
|
||||
stub_request(:get, 'https://example.com/alice').to_return(body: Oj.dump(actor), headers: { 'Content-Type': 'application/activity+json' })
|
||||
stub_request(:get, 'https://example.com/.well-known/webfinger?resource=acct:alice@example.com').to_return(body: Oj.dump(webfinger), headers: { 'Content-Type': 'application/jrd+json' })
|
||||
stub_request(:get, 'https://iscool.af/.well-known/webfinger?resource=acct:alice@iscool.af').to_return(body: Oj.dump(webfinger), headers: { 'Content-Type': 'application/jrd+json' })
|
||||
end
|
||||
|
@ -123,7 +123,7 @@ RSpec.describe ActivityPub::FetchRemoteAccountService, type: :service do
|
|||
let!(:webfinger) { { subject: 'acct:alice@example.com', links: [{ rel: 'self', href: 'https://example.com/bob' }] } }
|
||||
|
||||
before do
|
||||
stub_request(:get, 'https://example.com/alice').to_return(body: Oj.dump(actor))
|
||||
stub_request(:get, 'https://example.com/alice').to_return(body: Oj.dump(actor), headers: { 'Content-Type': 'application/activity+json' })
|
||||
stub_request(:get, 'https://example.com/.well-known/webfinger?resource=acct:alice@example.com').to_return(body: Oj.dump(webfinger), headers: { 'Content-Type': 'application/jrd+json' })
|
||||
end
|
||||
|
||||
|
@ -146,7 +146,7 @@ RSpec.describe ActivityPub::FetchRemoteAccountService, type: :service do
|
|||
let!(:webfinger) { { subject: 'acct:alice@iscool.af', links: [{ rel: 'self', href: 'https://example.com/bob' }] } }
|
||||
|
||||
before do
|
||||
stub_request(:get, 'https://example.com/alice').to_return(body: Oj.dump(actor))
|
||||
stub_request(:get, 'https://example.com/alice').to_return(body: Oj.dump(actor), headers: { 'Content-Type': 'application/activity+json' })
|
||||
stub_request(:get, 'https://example.com/.well-known/webfinger?resource=acct:alice@example.com').to_return(body: Oj.dump(webfinger), headers: { 'Content-Type': 'application/jrd+json' })
|
||||
stub_request(:get, 'https://iscool.af/.well-known/webfinger?resource=acct:alice@iscool.af').to_return(body: Oj.dump(webfinger), headers: { 'Content-Type': 'application/jrd+json' })
|
||||
end
|
||||
|
|
|
@ -16,7 +16,7 @@ RSpec.describe ActivityPub::FetchRemoteActorService, type: :service do
|
|||
end
|
||||
|
||||
describe '#call' do
|
||||
let(:account) { subject.call('https://example.com/alice', id: true) }
|
||||
let(:account) { subject.call('https://example.com/alice') }
|
||||
|
||||
shared_examples 'sets profile data' do
|
||||
it 'returns an account' do
|
||||
|
@ -42,7 +42,7 @@ RSpec.describe ActivityPub::FetchRemoteActorService, type: :service do
|
|||
before do
|
||||
actor[:inbox] = nil
|
||||
|
||||
stub_request(:get, 'https://example.com/alice').to_return(body: Oj.dump(actor))
|
||||
stub_request(:get, 'https://example.com/alice').to_return(body: Oj.dump(actor), headers: { 'Content-Type': 'application/activity+json' })
|
||||
stub_request(:get, 'https://example.com/.well-known/webfinger?resource=acct:alice@example.com').to_return(body: Oj.dump(webfinger), headers: { 'Content-Type': 'application/jrd+json' })
|
||||
end
|
||||
|
||||
|
@ -65,7 +65,7 @@ RSpec.describe ActivityPub::FetchRemoteActorService, type: :service do
|
|||
let!(:webfinger) { { subject: 'acct:alice@example.com', links: [{ rel: 'self', href: 'https://example.com/alice' }] } }
|
||||
|
||||
before do
|
||||
stub_request(:get, 'https://example.com/alice').to_return(body: Oj.dump(actor))
|
||||
stub_request(:get, 'https://example.com/alice').to_return(body: Oj.dump(actor), headers: { 'Content-Type': 'application/activity+json' })
|
||||
stub_request(:get, 'https://example.com/.well-known/webfinger?resource=acct:alice@example.com').to_return(body: Oj.dump(webfinger), headers: { 'Content-Type': 'application/jrd+json' })
|
||||
end
|
||||
|
||||
|
@ -91,7 +91,7 @@ RSpec.describe ActivityPub::FetchRemoteActorService, type: :service do
|
|||
let!(:webfinger) { { subject: 'acct:alice@iscool.af', links: [{ rel: 'self', href: 'https://example.com/alice' }] } }
|
||||
|
||||
before do
|
||||
stub_request(:get, 'https://example.com/alice').to_return(body: Oj.dump(actor))
|
||||
stub_request(:get, 'https://example.com/alice').to_return(body: Oj.dump(actor), headers: { 'Content-Type': 'application/activity+json' })
|
||||
stub_request(:get, 'https://example.com/.well-known/webfinger?resource=acct:alice@example.com').to_return(body: Oj.dump(webfinger), headers: { 'Content-Type': 'application/jrd+json' })
|
||||
stub_request(:get, 'https://iscool.af/.well-known/webfinger?resource=acct:alice@iscool.af').to_return(body: Oj.dump(webfinger), headers: { 'Content-Type': 'application/jrd+json' })
|
||||
end
|
||||
|
@ -123,7 +123,7 @@ RSpec.describe ActivityPub::FetchRemoteActorService, type: :service do
|
|||
let!(:webfinger) { { subject: 'acct:alice@example.com', links: [{ rel: 'self', href: 'https://example.com/bob' }] } }
|
||||
|
||||
before do
|
||||
stub_request(:get, 'https://example.com/alice').to_return(body: Oj.dump(actor))
|
||||
stub_request(:get, 'https://example.com/alice').to_return(body: Oj.dump(actor), headers: { 'Content-Type': 'application/activity+json' })
|
||||
stub_request(:get, 'https://example.com/.well-known/webfinger?resource=acct:alice@example.com').to_return(body: Oj.dump(webfinger), headers: { 'Content-Type': 'application/jrd+json' })
|
||||
end
|
||||
|
||||
|
@ -146,7 +146,7 @@ RSpec.describe ActivityPub::FetchRemoteActorService, type: :service do
|
|||
let!(:webfinger) { { subject: 'acct:alice@iscool.af', links: [{ rel: 'self', href: 'https://example.com/bob' }] } }
|
||||
|
||||
before do
|
||||
stub_request(:get, 'https://example.com/alice').to_return(body: Oj.dump(actor))
|
||||
stub_request(:get, 'https://example.com/alice').to_return(body: Oj.dump(actor), headers: { 'Content-Type': 'application/activity+json' })
|
||||
stub_request(:get, 'https://example.com/.well-known/webfinger?resource=acct:alice@example.com').to_return(body: Oj.dump(webfinger), headers: { 'Content-Type': 'application/jrd+json' })
|
||||
stub_request(:get, 'https://iscool.af/.well-known/webfinger?resource=acct:alice@iscool.af').to_return(body: Oj.dump(webfinger), headers: { 'Content-Type': 'application/jrd+json' })
|
||||
end
|
||||
|
|
|
@ -38,16 +38,16 @@ RSpec.describe ActivityPub::FetchRemoteKeyService, type: :service do
|
|||
end
|
||||
|
||||
before do
|
||||
stub_request(:get, 'https://example.com/alice').to_return(body: Oj.dump(actor))
|
||||
stub_request(:get, 'https://example.com/alice').to_return(body: Oj.dump(actor), headers: { 'Content-Type': 'application/activity+json' })
|
||||
stub_request(:get, 'https://example.com/.well-known/webfinger?resource=acct:alice@example.com').to_return(body: Oj.dump(webfinger), headers: { 'Content-Type': 'application/jrd+json' })
|
||||
end
|
||||
|
||||
describe '#call' do
|
||||
let(:account) { subject.call(public_key_id, id: false) }
|
||||
let(:account) { subject.call(public_key_id) }
|
||||
|
||||
context 'when the key is a sub-object from the actor' do
|
||||
before do
|
||||
stub_request(:get, public_key_id).to_return(body: Oj.dump(actor))
|
||||
stub_request(:get, public_key_id).to_return(body: Oj.dump(actor), headers: { 'Content-Type': 'application/activity+json' })
|
||||
end
|
||||
|
||||
it 'returns the expected account' do
|
||||
|
@ -59,7 +59,7 @@ RSpec.describe ActivityPub::FetchRemoteKeyService, type: :service do
|
|||
let(:public_key_id) { 'https://example.com/alice-public-key.json' }
|
||||
|
||||
before do
|
||||
stub_request(:get, public_key_id).to_return(body: Oj.dump(key_json.merge({ '@context': ['https://www.w3.org/ns/activitystreams', 'https://w3id.org/security/v1'] })))
|
||||
stub_request(:get, public_key_id).to_return(body: Oj.dump(key_json.merge({ '@context': ['https://www.w3.org/ns/activitystreams', 'https://w3id.org/security/v1'] })), headers: { 'Content-Type': 'application/activity+json' })
|
||||
end
|
||||
|
||||
it 'returns the expected account' do
|
||||
|
@ -72,7 +72,7 @@ RSpec.describe ActivityPub::FetchRemoteKeyService, type: :service do
|
|||
let(:actor_public_key) { 'https://example.com/alice-public-key.json' }
|
||||
|
||||
before do
|
||||
stub_request(:get, public_key_id).to_return(body: Oj.dump(key_json.merge({ '@context': ['https://www.w3.org/ns/activitystreams', 'https://w3id.org/security/v1'] })))
|
||||
stub_request(:get, public_key_id).to_return(body: Oj.dump(key_json.merge({ '@context': ['https://www.w3.org/ns/activitystreams', 'https://w3id.org/security/v1'] })), headers: { 'Content-Type': 'application/activity+json' })
|
||||
end
|
||||
|
||||
it 'returns the nil' do
|
||||
|
|
|
@ -32,6 +32,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'])
|
||||
|
@ -41,7 +53,7 @@ RSpec.describe ActivityPub::FetchRepliesService, type: :service do
|
|||
|
||||
context 'when passing the URL to the collection' do
|
||||
before do
|
||||
stub_request(:get, collection_uri).to_return(status: 200, body: Oj.dump(payload))
|
||||
stub_request(:get, collection_uri).to_return(status: 200, body: Oj.dump(payload), headers: { 'Content-Type': 'application/activity+json' })
|
||||
end
|
||||
|
||||
it 'spawns workers for up to 5 replies on the same server' do
|
||||
|
@ -70,7 +82,7 @@ RSpec.describe ActivityPub::FetchRepliesService, type: :service do
|
|||
|
||||
context 'when passing the URL to the collection' do
|
||||
before do
|
||||
stub_request(:get, collection_uri).to_return(status: 200, body: Oj.dump(payload))
|
||||
stub_request(:get, collection_uri).to_return(status: 200, body: Oj.dump(payload), headers: { 'Content-Type': 'application/activity+json' })
|
||||
end
|
||||
|
||||
it 'spawns workers for up to 5 replies on the same server' do
|
||||
|
@ -103,7 +115,7 @@ RSpec.describe ActivityPub::FetchRepliesService, type: :service do
|
|||
|
||||
context 'when passing the URL to the collection' do
|
||||
before do
|
||||
stub_request(:get, collection_uri).to_return(status: 200, body: Oj.dump(payload))
|
||||
stub_request(:get, collection_uri).to_return(status: 200, body: Oj.dump(payload), headers: { 'Content-Type': 'application/activity+json' })
|
||||
end
|
||||
|
||||
it 'spawns workers for up to 5 replies on the same server' do
|
||||
|
|
|
@ -58,7 +58,7 @@ RSpec.describe ActivityPub::SynchronizeFollowersService, type: :service do
|
|||
describe '#call' do
|
||||
context 'when the endpoint is a Collection of actor URIs' do
|
||||
before do
|
||||
stub_request(:get, collection_uri).to_return(status: 200, body: Oj.dump(payload))
|
||||
stub_request(:get, collection_uri).to_return(status: 200, body: Oj.dump(payload), headers: { 'Content-Type': 'application/activity+json' })
|
||||
end
|
||||
|
||||
it_behaves_like 'synchronizes followers'
|
||||
|
@ -75,7 +75,7 @@ RSpec.describe ActivityPub::SynchronizeFollowersService, type: :service do
|
|||
end
|
||||
|
||||
before do
|
||||
stub_request(:get, collection_uri).to_return(status: 200, body: Oj.dump(payload))
|
||||
stub_request(:get, collection_uri).to_return(status: 200, body: Oj.dump(payload), headers: { 'Content-Type': 'application/activity+json' })
|
||||
end
|
||||
|
||||
it_behaves_like 'synchronizes followers'
|
||||
|
@ -96,7 +96,7 @@ RSpec.describe ActivityPub::SynchronizeFollowersService, type: :service do
|
|||
end
|
||||
|
||||
before do
|
||||
stub_request(:get, collection_uri).to_return(status: 200, body: Oj.dump(payload))
|
||||
stub_request(:get, collection_uri).to_return(status: 200, body: Oj.dump(payload), headers: { 'Content-Type': 'application/activity+json' })
|
||||
end
|
||||
|
||||
it_behaves_like 'synchronizes followers'
|
||||
|
|
|
@ -54,7 +54,7 @@ RSpec.describe FetchResourceService, type: :service do
|
|||
|
||||
let(:json) do
|
||||
{
|
||||
id: 1,
|
||||
id: 'http://example.com/foo',
|
||||
'@context': ActivityPub::TagManager::CONTEXT,
|
||||
type: 'Note',
|
||||
}.to_json
|
||||
|
@ -79,14 +79,14 @@ RSpec.describe FetchResourceService, type: :service do
|
|||
let(:content_type) { 'application/activity+json; charset=utf-8' }
|
||||
let(:body) { json }
|
||||
|
||||
it { is_expected.to eq [1, { prefetched_body: body, id: true }] }
|
||||
it { is_expected.to eq ['http://example.com/foo', { prefetched_body: body }] }
|
||||
end
|
||||
|
||||
context 'when content type is ld+json with profile' do
|
||||
let(:content_type) { 'application/ld+json; profile="https://www.w3.org/ns/activitystreams"' }
|
||||
let(:body) { json }
|
||||
|
||||
it { is_expected.to eq [1, { prefetched_body: body, id: true }] }
|
||||
it { is_expected.to eq ['http://example.com/foo', { prefetched_body: body }] }
|
||||
end
|
||||
|
||||
before do
|
||||
|
@ -97,14 +97,14 @@ RSpec.describe FetchResourceService, type: :service do
|
|||
context 'when link header is present' do
|
||||
let(:headers) { { 'Link' => '<http://example.com/foo>; rel="alternate"; type="application/activity+json"', } }
|
||||
|
||||
it { is_expected.to eq [1, { prefetched_body: json, id: true }] }
|
||||
it { is_expected.to eq ['http://example.com/foo', { prefetched_body: json }] }
|
||||
end
|
||||
|
||||
context 'when content type is text/html' do
|
||||
let(:content_type) { 'text/html' }
|
||||
let(:body) { '<html><head><link rel="alternate" href="http://example.com/foo" type="application/activity+json"/></head></html>' }
|
||||
|
||||
it { is_expected.to eq [1, { prefetched_body: json, id: true }] }
|
||||
it { is_expected.to eq ['http://example.com/foo', { prefetched_body: json }] }
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
@ -69,9 +69,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
|
||||
|
|
|
@ -108,4 +108,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
|
||||
|
|
|
@ -139,6 +139,7 @@ describe ResolveURLService, type: :service do
|
|||
stub_request(:get, url).to_return(status: 302, headers: { 'Location' => status_url })
|
||||
body = ActiveModelSerializers::SerializableResource.new(status, serializer: ActivityPub::NoteSerializer, adapter: ActivityPub::Adapter).to_json
|
||||
stub_request(:get, status_url).to_return(body: body, headers: { 'Content-Type' => 'application/activity+json' })
|
||||
stub_request(:get, uri).to_return(body: body, headers: { 'Content-Type' => 'application/activity+json' })
|
||||
end
|
||||
|
||||
it 'returns status by url' do
|
||||
|
|
7
spec/support/omniauth_mocks.rb
Normal file
7
spec/support/omniauth_mocks.rb
Normal file
|
@ -0,0 +1,7 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
OmniAuth.config.test_mode = true
|
||||
|
||||
def mock_omniauth(provider, data)
|
||||
OmniAuth.config.mock_auth[provider] = OmniAuth::AuthHash.new(data)
|
||||
end
|
|
@ -21,7 +21,7 @@ describe ActivityPub::FetchRepliesWorker do
|
|||
|
||||
describe 'perform' do
|
||||
it 'performs a request if the collection URI is from the same host' do
|
||||
stub_request(:get, 'https://example.com/statuses_replies/1').to_return(status: 200, body: json)
|
||||
stub_request(:get, 'https://example.com/statuses_replies/1').to_return(status: 200, body: json, headers: { 'Content-Type': 'application/activity+json' })
|
||||
subject.perform(status.id, 'https://example.com/statuses_replies/1')
|
||||
expect(a_request(:get, 'https://example.com/statuses_replies/1')).to have_been_made.once
|
||||
end
|
||||
|
|
Loading…
Reference in a new issue