diff --git a/SECURITY.md b/SECURITY.md index 12052652e6..e5790a66fa 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -18,5 +18,4 @@ A "vulnerability in Mastodon" is a vulnerability in the code distributed through | 4.5.x | Yes | | 4.4.x | Yes | | 4.3.x | Until 2026-05-06 | -| 4.2.x | Until 2026-01-08 | -| < 4.2 | No | +| < 4.3 | No | diff --git a/app/controllers/api/v1/statuses_controller.rb b/app/controllers/api/v1/statuses_controller.rb index 370fbacbf4..1a153accf3 100644 --- a/app/controllers/api/v1/statuses_controller.rb +++ b/app/controllers/api/v1/statuses_controller.rb @@ -107,9 +107,7 @@ class Api::V1::StatusesController < Api::BaseController @status = Status.where(account: current_account).find(params[:id]) authorize @status, :update? - UpdateStatusService.new.call( - @status, - current_account.id, + update_options = { text: status_params[:status], media_ids: status_params[:media_ids], media_attributes: status_params[:media_attributes], @@ -117,9 +115,12 @@ class Api::V1::StatusesController < Api::BaseController language: status_params[:language], spoiler_text: status_params[:spoiler_text], poll: status_params[:poll], - quote_approval_policy: quote_approval_policy, - content_type: status_params[:content_type] - ) + content_type: status_params[:content_type], + } + + update_options[:quote_approval_policy] = quote_approval_policy if status_params[:quote_approval_policy].present? + + UpdateStatusService.new.call(@status, current_account.id, update_options) render json: @status, serializer: REST::StatusSerializer end diff --git a/app/controllers/concerns/cache_concern.rb b/app/controllers/concerns/cache_concern.rb index b1b09f2aab..3527cdaca0 100644 --- a/app/controllers/concerns/cache_concern.rb +++ b/app/controllers/concerns/cache_concern.rb @@ -19,7 +19,7 @@ module CacheConcern # from being used as cache keys, while allowing to `Vary` on them (to not serve # anonymous cached data to authenticated requests when authentication matters) def enforce_cache_control! - vary = response.headers['Vary']&.split&.map { |x| x.strip.downcase } + vary = response.headers['Vary'].to_s.split(',').map { |x| x.strip.downcase }.reject(&:empty?) return unless vary.present? && %w(cookie authorization signature).any? { |header| vary.include?(header) && request.headers[header].present? } response.cache_control.replace(private: true, no_store: true) diff --git a/app/javascript/flavours/glitch/features/emoji/normalize.test.ts b/app/javascript/flavours/glitch/features/emoji/normalize.test.ts index b4c7669961..8222ab81e5 100644 --- a/app/javascript/flavours/glitch/features/emoji/normalize.test.ts +++ b/app/javascript/flavours/glitch/features/emoji/normalize.test.ts @@ -33,6 +33,7 @@ describe('emojiToUnicodeHex', () => { ['⚫', '26AB'], ['🖤', '1F5A4'], ['💀', '1F480'], + ['❤️', '2764'], // Checks for trailing variation selector removal. ['💂‍♂️', '1F482-200D-2642-FE0F'], ] as const)( 'emojiToUnicodeHex converts %s to %s', diff --git a/app/javascript/flavours/glitch/features/emoji/normalize.ts b/app/javascript/flavours/glitch/features/emoji/normalize.ts index f0a502dcb5..d8aa1c29f0 100644 --- a/app/javascript/flavours/glitch/features/emoji/normalize.ts +++ b/app/javascript/flavours/glitch/features/emoji/normalize.ts @@ -30,6 +30,12 @@ export function emojiToUnicodeHex(emoji: string): string { codes.push(code); } } + + // Handles how Emojibase removes the variation selector for single code emojis. + // See: https://emojibase.dev/docs/spec/#merged-variation-selectors + if (codes.at(1) === VARIATION_SELECTOR_CODE && codes.length === 2) { + codes.pop(); + } return hexNumbersToString(codes); } diff --git a/app/javascript/flavours/glitch/styles/admin.scss b/app/javascript/flavours/glitch/styles/admin.scss index 3199358730..7a36db424c 100644 --- a/app/javascript/flavours/glitch/styles/admin.scss +++ b/app/javascript/flavours/glitch/styles/admin.scss @@ -163,6 +163,7 @@ $content-width: 840px; width: 100%; max-width: $content-width; flex: 1 1 auto; + isolation: isolate; } @media screen and (max-width: ($content-width + $sidebar-width)) { diff --git a/app/javascript/mastodon/features/emoji/normalize.test.ts b/app/javascript/mastodon/features/emoji/normalize.test.ts index b4c7669961..8222ab81e5 100644 --- a/app/javascript/mastodon/features/emoji/normalize.test.ts +++ b/app/javascript/mastodon/features/emoji/normalize.test.ts @@ -33,6 +33,7 @@ describe('emojiToUnicodeHex', () => { ['⚫', '26AB'], ['🖤', '1F5A4'], ['💀', '1F480'], + ['❤️', '2764'], // Checks for trailing variation selector removal. ['💂‍♂️', '1F482-200D-2642-FE0F'], ] as const)( 'emojiToUnicodeHex converts %s to %s', diff --git a/app/javascript/mastodon/features/emoji/normalize.ts b/app/javascript/mastodon/features/emoji/normalize.ts index a09505e97f..59304c89e8 100644 --- a/app/javascript/mastodon/features/emoji/normalize.ts +++ b/app/javascript/mastodon/features/emoji/normalize.ts @@ -30,6 +30,12 @@ export function emojiToUnicodeHex(emoji: string): string { codes.push(code); } } + + // Handles how Emojibase removes the variation selector for single code emojis. + // See: https://emojibase.dev/docs/spec/#merged-variation-selectors + if (codes.at(1) === VARIATION_SELECTOR_CODE && codes.length === 2) { + codes.pop(); + } return hexNumbersToString(codes); } diff --git a/app/javascript/styles/mastodon/admin.scss b/app/javascript/styles/mastodon/admin.scss index 8f892ce209..04c86ce834 100644 --- a/app/javascript/styles/mastodon/admin.scss +++ b/app/javascript/styles/mastodon/admin.scss @@ -163,6 +163,7 @@ $content-width: 840px; width: 100%; max-width: $content-width; flex: 1 1 auto; + isolation: isolate; } @media screen and (max-width: ($content-width + $sidebar-width)) { diff --git a/app/lib/activitypub/activity.rb b/app/lib/activitypub/activity.rb index 64ee9acd05..d07d1c2f24 100644 --- a/app/lib/activitypub/activity.rb +++ b/app/lib/activitypub/activity.rb @@ -21,14 +21,13 @@ class ActivityPub::Activity class << self def factory(json, account, **) - @json = json - klass&.new(json, account, **) + klass_for(json)&.new(json, account, **) end private - def klass - case @json['type'] + def klass_for(json) + case json['type'] when 'Create' ActivityPub::Activity::Create when 'Announce' diff --git a/app/lib/activitypub/activity/quote_request.rb b/app/lib/activitypub/activity/quote_request.rb index 12f48ebb2b..46c45cde27 100644 --- a/app/lib/activitypub/activity/quote_request.rb +++ b/app/lib/activitypub/activity/quote_request.rb @@ -47,7 +47,7 @@ class ActivityPub::Activity::QuoteRequest < ActivityPub::Activity # NOTE: Replacing the object's context by that of the parent activity is # not sound, but it's consistent with the rest of the codebase instrument = @json['instrument'].merge({ '@context' => @json['@context'] }) - return if non_matching_uri_hosts?(instrument['id'], @account.uri) + return if non_matching_uri_hosts?(@account.uri, instrument['id']) ActivityPub::FetchRemoteStatusService.new.call(instrument['id'], prefetched_body: instrument, on_behalf_of: quoted_status.account, request_id: @options[:request_id]) end diff --git a/app/lib/activitypub/tag_manager.rb b/app/lib/activitypub/tag_manager.rb index 43574d3657..3174d1792e 100644 --- a/app/lib/activitypub/tag_manager.rb +++ b/app/lib/activitypub/tag_manager.rb @@ -50,7 +50,7 @@ class ActivityPub::TagManager context_url(target) unless target.parent_account_id.nil? || target.parent_status_id.nil? when :note, :comment, :activity if target.account.numeric_ap_id? - return activity_ap_account_status_url(target.account, target) if target.reblog? + return activity_ap_account_status_url(target.account.id, target) if target.reblog? ap_account_status_url(target.account.id, target) else diff --git a/app/lib/connection_pool/shared_connection_pool.rb b/app/lib/connection_pool/shared_connection_pool.rb index 1cfcc5823b..c7dd747eda 100644 --- a/app/lib/connection_pool/shared_connection_pool.rb +++ b/app/lib/connection_pool/shared_connection_pool.rb @@ -41,12 +41,17 @@ class ConnectionPool::SharedConnectionPool < ConnectionPool # ConnectionPool 2.4+ calls `checkin(force: true)` after fork. # When this happens, we should remove all connections from Thread.current - ::Thread.current.keys.each do |name| # rubocop:disable Style/HashEachMethods - next unless name.to_s.start_with?("#{@key}-") + connection_keys = ::Thread.current.keys.select { |key| key.to_s.start_with?("#{@key}-") && !key.to_s.start_with?("#{@key_count}-") } + count_keys = ::Thread.current.keys.select { |key| key.to_s.start_with?("#{@key_count}-") } - @available.push(::Thread.current[name]) - ::Thread.current[name] = nil + connection_keys.each do |key| + @available.push(::Thread.current[key]) + ::Thread.current[key] = nil end + count_keys.each do |key| + ::Thread.current[key] = nil + end + elsif ::Thread.current[key(preferred_tag)] if ::Thread.current[key_count(preferred_tag)] == 1 @available.push(::Thread.current[key(preferred_tag)]) diff --git a/app/lib/feed_manager.rb b/app/lib/feed_manager.rb index fde31ad757..62a5563553 100644 --- a/app/lib/feed_manager.rb +++ b/app/lib/feed_manager.rb @@ -500,6 +500,7 @@ class FeedManager return :filter if status.reply? && (status.in_reply_to_id.nil? || status.in_reply_to_account_id.nil?) return :skip_home if timeline_type != :list && crutches[:exclusive_list_users][status.account_id].present? return :filter if crutches[:languages][status.account_id].present? && status.language.present? && !crutches[:languages][status.account_id].include?(status.language) + return :filter if status.reblog? && status.reblog.blank? check_for_blocks = crutches[:active_mentions][status.id] || [] check_for_blocks.push(status.account_id) diff --git a/app/lib/signature_parser.rb b/app/lib/signature_parser.rb index 7a75080d98..00a45b8251 100644 --- a/app/lib/signature_parser.rb +++ b/app/lib/signature_parser.rb @@ -25,9 +25,13 @@ class SignatureParser # Use `skip` instead of `scan` as we only care about the subgroups while scanner.skip(PARAM_RE) + key = scanner[:key] + # Detect a duplicate key + raise Mastodon::SignatureVerificationError, 'Error parsing signature with duplicate keys' if params.key?(key) + # This is not actually correct with regards to quoted pairs, but it's consistent # with our previous implementation, and good enough in practice. - params[scanner[:key]] = scanner[:value] || scanner[:quoted_value][1...-1] + params[key] = scanner[:value] || scanner[:quoted_value][1...-1] scanner.skip(/\s*/) return params if scanner.eos? diff --git a/app/services/batched_remove_status_service.rb b/app/services/batched_remove_status_service.rb index 9326eed26c..9071543b63 100644 --- a/app/services/batched_remove_status_service.rb +++ b/app/services/batched_remove_status_service.rb @@ -34,7 +34,7 @@ class BatchedRemoveStatusService < BaseService # transaction lock the database, but we use the delete method instead # of destroy to avoid all callbacks. We rely on foreign keys to # cascade the delete faster without loading the associations. - statuses_and_reblogs.each_slice(50) { |slice| Status.where(id: slice.map(&:id)).delete_all } + statuses_and_reblogs.each_slice(50) { |slice| Status.unscoped.where(id: slice.pluck(:id)).delete_all } # Since we skipped all callbacks, we also need to manually # deindex the statuses diff --git a/spec/lib/activitypub/tag_manager_spec.rb b/spec/lib/activitypub/tag_manager_spec.rb index cad46ad903..6cbb58055e 100644 --- a/spec/lib/activitypub/tag_manager_spec.rb +++ b/spec/lib/activitypub/tag_manager_spec.rb @@ -128,6 +128,28 @@ RSpec.describe ActivityPub::TagManager do .to eq("#{host_prefix}/ap/users/#{status.account.id}/statuses/#{status.id}") end end + + context 'with a reblog' do + let(:status) { Fabricate(:status, account:, reblog: Fabricate(:status)) } + + context 'when using a numeric ID based scheme' do + let(:account) { Fabricate(:account, id_scheme: :numeric_ap_id) } + + it 'returns a string starting with web domain and with the expected path' do + expect(subject.uri_for(status)) + .to eq("#{host_prefix}/ap/users/#{status.account.id}/statuses/#{status.id}/activity") + end + end + + context 'when using the legacy username based scheme' do + let(:account) { Fabricate(:account, id_scheme: :username_ap_id) } + + it 'returns a string starting with web domain and with the expected path' do + expect(subject.uri_for(status)) + .to eq("#{host_prefix}/users/#{status.account.username}/statuses/#{status.id}/activity") + end + end + end end context 'with a remote status' do diff --git a/spec/requests/api/v1/statuses_spec.rb b/spec/requests/api/v1/statuses_spec.rb index 2cb67272b7..a89be2aa25 100644 --- a/spec/requests/api/v1/statuses_spec.rb +++ b/spec/requests/api/v1/statuses_spec.rb @@ -508,6 +508,15 @@ RSpec.describe '/api/v1/statuses' do .to start_with('application/json') end end + + context 'when status has non-default quote policy and param is omitted' do + let(:status) { Fabricate(:status, account: user.account, quote_approval_policy: 'nobody') } + + it 'preserves existing quote approval policy' do + expect { subject } + .to_not(change { status.reload.quote_approval_policy }) + end + end end end