Compare commits

...

28 commits

Author SHA1 Message Date
Mike Barnes 372e262c4d Merge tag 'v4.1.15' 2024-02-17 10:52:48 +11:00
Claire b7b03e8d26 Bump version to v4.1.15 2024-02-16 11:57:15 +01:00
Claire a07fff079b
Merge pull request from GHSA-jhrq-qvrm-qr36
* Fix insufficient Content-Type checking of fetched ActivityStreams objects

* Allow JSON-LD documents with multiple profiles
2024-02-16 11:56:12 +01:00
Claire 6f29d50aa5 Update dependency pg to 1.5.5 2024-02-16 09:42:31 +01:00
Claire 9e5af6bb58 Fix user creation failure handling in OAuth paths (#29207)
Co-authored-by: Matt Jankowski <matt@jankowski.online>
2024-02-14 23:16:39 +01:00
Claire 6499850ac4 Bump version to v4.1.14 2024-02-14 15:16:55 +01:00
Claire 6f36b633a7
Merge pull request from GHSA-vm39-j3vx-pch3
* Prevent different identities from a same SSO provider from accessing a same account

* Lock auth provider changes behind `ALLOW_UNSAFE_AUTH_PROVIDER_REATTACH=true`

* Rename methods to avoid confusion between OAuth and OmniAuth
2024-02-14 15:16:07 +01:00
Claire d807b3960e
Merge pull request from GHSA-7w3c-p9j8-mq3x
* Ensure destruction of OAuth Applications notifies streaming

Due to doorkeeper using a dependent: delete_all relationship, the destroy of an OAuth Application bypassed the existing AccessTokenExtension callbacks for announcing destructing of access tokens.

* Ensure password resets revoke access to Streaming API

* Improve performance of deleting OAuth tokens

---------

Co-authored-by: Emelia Smith <ThisIsMissEm@users.noreply.github.com>
2024-02-14 15:15:34 +01:00
Claire 2f6518cae2 Add sidekiq_unique_jobs:delete_all_locks task and disable sidekiq-unique-jobs UI by default (#29199) 2024-02-14 13:17:55 +01:00
Emelia Smith cdbe2855f3 Disable administrative doorkeeper routes (#29187) 2024-02-14 11:34:46 +01:00
blah fdde3cdb4e Update dependency sidekiq-unique-jobs to 7.1.33 2024-02-14 11:34:46 +01:00
blah ce9c641d9a Update dependency nokogiri to 1.16.2 2024-02-14 11:26:27 +01:00
Mike Barnes 1064e395cb Merge tag 'v4.1.13' 2024-02-02 02:13:17 +11:00
Claire 5799bc4af7
Merge pull request from GHSA-3fjr-858r-92rw
* Fix insufficient origin validation

* Bump version to v4.1.13
2024-02-01 15:56:46 +01:00
Mike Barnes bd7ce0d5f9 Merge tag 'v4.1.12' 2024-01-30 21:39:45 +11:00
Claire fc4e2eca9f Bump version to v4.1.12 2024-01-24 15:31:06 +01:00
Claire 2e8943aecd Add rate-limit of TOTP authentication attempts at controller level (#28801) 2024-01-24 15:31:06 +01:00
Claire e6072a8d13 Fix error when processing remote files with unusually long names (#28823) 2024-01-24 15:31:06 +01:00
Claire 460e4fbdd6 Fix processing of compacted single-item JSON-LD collections (#28816) 2024-01-24 15:31:06 +01:00
Jonathan de Jong de60322711 Retry 401 errors on replies fetching (#28788)
Co-authored-by: Claire <claire.github-309c@sitedethib.com>
2024-01-24 15:31:06 +01:00
Jeong Arm 90bb870680 Ignore RecordNotUnique errors in LinkCrawlWorker (#28748) 2024-01-24 15:31:06 +01:00
Claire 9292d998fe Fix Mastodon not correctly processing HTTP Signatures with query strings (#28476) 2024-01-24 15:31:06 +01:00
Claire 92643f48de Convert signature verification specs to request specs (#28443) 2024-01-24 15:31:06 +01:00
Claire 458620bdd4 Fix potential redirection loop of streaming endpoint (#28665) 2024-01-24 15:31:06 +01:00
Claire a1a71263e0 Fix streaming API redirection ignoring the port of streaming_api_base_url (#28558) 2024-01-24 15:31:06 +01:00
MitarashiDango 4c5575e8e0 Fix Undo Announce activity is not sent, when not followed by the reblogged post author (#18482)
Co-authored-by: Claire <claire.github-309c@sitedethib.com>
2024-01-24 15:31:06 +01:00
Claire a2ddd849e2 Fix LinkCrawlWorker error when encountering empty OEmbed response (#28268) 2024-01-24 15:31:06 +01:00
Claire 2e4d43933d
Fix SQL query in /api/v1/directory (#28412) 2023-12-18 11:03:20 +01:00
65 changed files with 1245 additions and 475 deletions

View file

@ -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

View file

@ -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)

View file

@ -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 |

View file

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

View file

@ -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

View file

@ -1,6 +1,10 @@
# frozen_string_literal: true
class Auth::SessionsController < Devise::SessionsController
include Redisable
MAX_2FA_ATTEMPTS_PER_HOUR = 10
layout 'auth'
skip_before_action :require_no_authentication, only: [:create]
@ -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

View file

@ -91,14 +91,23 @@ module SignatureVerification
raise SignatureVerificationError, "Public key not found for key #{signature_params['keyId']}" if actor.nil?
signature = Base64.decode64(signature_params['signature'])
compare_signed_string = build_signed_string
compare_signed_string = build_signed_string(include_query_string: true)
return actor unless verify_signature(actor, signature, compare_signed_string).nil?
# Compatibility quirk with older Mastodon versions
compare_signed_string = build_signed_string(include_query_string: false)
return actor unless verify_signature(actor, signature, compare_signed_string).nil?
actor = stoplight_wrap_request { actor_refresh_key!(actor) }
raise SignatureVerificationError, "Could not refresh public key #{signature_params['keyId']}" if actor.nil?
compare_signed_string = build_signed_string(include_query_string: true)
return actor unless verify_signature(actor, signature, compare_signed_string).nil?
# Compatibility quirk with older Mastodon versions
compare_signed_string = build_signed_string(include_query_string: false)
return actor unless verify_signature(actor, signature, compare_signed_string).nil?
fail_with! "Verification failed for #{actor.to_log_human_identifier} #{actor.uri} using rsa-sha256 (RSASSA-PKCS1-v1_5 with SHA-256)", signed_string: compare_signed_string, signature: signature_params['signature']
@ -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

View file

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

View file

@ -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

View file

@ -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

View file

@ -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?

View file

@ -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

View file

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

View file

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

View file

@ -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')) }

View file

@ -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)
{

View file

@ -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

View file

@ -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

View file

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

View file

@ -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)

View file

@ -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}"

View file

@ -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)

View file

@ -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

View file

@ -26,9 +26,9 @@ class ActivityPub::FetchRepliesService < BaseService
case collection['type']
when 'Collection', 'CollectionPage'
collection['items']
as_array(collection['items'])
when 'OrderedCollection', 'OrderedCollectionPage'
collection['orderedItems']
as_array(collection['orderedItems'])
end
end
@ -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

View file

@ -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

View file

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

View file

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

View file

@ -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)

View file

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

View file

@ -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)

View file

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

View file

@ -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).

View file

@ -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.

View file

@ -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:

View file

@ -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

View file

@ -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

View file

@ -13,7 +13,7 @@ module Mastodon
end
def patch
11
15
end
def flags

View file

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

View file

@ -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

View file

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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View 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

View 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

View 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

View file

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

View file

@ -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

View file

@ -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'

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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'

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View 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

View file

@ -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