From 69b09edcd214debdb5d867b3e6dc0b675c6f0649 Mon Sep 17 00:00:00 2001 From: Claire Date: Tue, 20 Jan 2026 15:10:38 +0100 Subject: [PATCH 1/5] Merge commit from fork --- app/controllers/api/web/push_subscriptions_controller.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/controllers/api/web/push_subscriptions_controller.rb b/app/controllers/api/web/push_subscriptions_controller.rb index 167d16fc4d..4dacfd6dbb 100644 --- a/app/controllers/api/web/push_subscriptions_controller.rb +++ b/app/controllers/api/web/push_subscriptions_controller.rb @@ -55,7 +55,7 @@ class Api::Web::PushSubscriptionsController < Api::Web::BaseController end def set_push_subscription - @push_subscription = ::Web::PushSubscription.find(params[:id]) + @push_subscription = ::Web::PushSubscription.where(user_id: active_session.user_id).find(params[:id]) end def subscription_params From 0a58ed03035c83ad19801e3f79d3b72c2969a90e Mon Sep 17 00:00:00 2001 From: Claire Date: Tue, 20 Jan 2026 15:13:09 +0100 Subject: [PATCH 2/5] Merge commit from fork --- app/models/custom_filter.rb | 3 +++ app/models/custom_filter_keyword.rb | 4 +++- app/models/list.rb | 3 ++- 3 files changed, 8 insertions(+), 2 deletions(-) diff --git a/app/models/custom_filter.rb b/app/models/custom_filter.rb index bacf158261..10925ffd95 100644 --- a/app/models/custom_filter.rb +++ b/app/models/custom_filter.rb @@ -30,6 +30,8 @@ class CustomFilter < ApplicationRecord EXPIRATION_DURATIONS = [30.minutes, 1.hour, 6.hours, 12.hours, 1.day, 1.week].freeze + TITLE_LENGTH_LIMIT = 256 + include Expireable include Redisable @@ -41,6 +43,7 @@ class CustomFilter < ApplicationRecord accepts_nested_attributes_for :keywords, reject_if: :all_blank, allow_destroy: true validates :title, :context, presence: true + validates :title, length: { maximum: TITLE_LENGTH_LIMIT } validate :context_must_be_valid normalizes :context, with: ->(context) { context.map(&:strip).filter_map(&:presence) } diff --git a/app/models/custom_filter_keyword.rb b/app/models/custom_filter_keyword.rb index 112798b10a..1abec4ddc4 100644 --- a/app/models/custom_filter_keyword.rb +++ b/app/models/custom_filter_keyword.rb @@ -17,7 +17,9 @@ class CustomFilterKeyword < ApplicationRecord belongs_to :custom_filter - validates :keyword, presence: true + KEYWORD_LENGTH_LIMIT = 512 + + validates :keyword, presence: true, length: { maximum: KEYWORD_LENGTH_LIMIT } alias_attribute :phrase, :keyword diff --git a/app/models/list.rb b/app/models/list.rb index cd01774539..d5dc04b395 100644 --- a/app/models/list.rb +++ b/app/models/list.rb @@ -17,6 +17,7 @@ class List < ApplicationRecord include Paginable PER_ACCOUNT_LIMIT = 50 + TITLE_LENGTH_LIMIT = 256 enum :replies_policy, { list: 0, followed: 1, none: 2 }, prefix: :show @@ -26,7 +27,7 @@ class List < ApplicationRecord has_many :accounts, through: :list_accounts has_many :active_accounts, -> { merge(ListAccount.active) }, through: :list_accounts, source: :account - validates :title, presence: true + validates :title, presence: true, length: { maximum: TITLE_LENGTH_LIMIT } validate :validate_account_lists_limit, on: :create From d5050227e957babca191aef5f23c3e7fd3ed8450 Mon Sep 17 00:00:00 2001 From: Claire Date: Tue, 20 Jan 2026 15:13:42 +0100 Subject: [PATCH 3/5] Merge commit from fork --- app/lib/activitypub/activity/update.rb | 3 ++- app/services/fan_out_on_write_service.rb | 2 ++ 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/app/lib/activitypub/activity/update.rb b/app/lib/activitypub/activity/update.rb index 5185507bdc..3eac307d07 100644 --- a/app/lib/activitypub/activity/update.rb +++ b/app/lib/activitypub/activity/update.rb @@ -32,7 +32,8 @@ class ActivityPub::Activity::Update < ActivityPub::Activity @status = Status.find_by(uri: object_uri, account_id: @account.id) # Ignore updates for old unknown objects, since those are updates we are not interested in - return if @status.nil? && object_too_old? + # Also ignore unknown objects from suspended users for the same reasons + return if @status.nil? && (@account.suspended? || object_too_old?) # We may be getting `Create` and `Update` out of order @status ||= ActivityPub::Activity::Create.new(@json, @account, **@options).perform diff --git a/app/services/fan_out_on_write_service.rb b/app/services/fan_out_on_write_service.rb index 3c084bc857..a06157e83b 100644 --- a/app/services/fan_out_on_write_service.rb +++ b/app/services/fan_out_on_write_service.rb @@ -14,6 +14,8 @@ class FanOutOnWriteService < BaseService @account = status.account @options = options + return if @status.proper.account.suspended? + check_race_condition! warm_payload_cache! From 8beb150516a83823c67aec6707b18fca352bd932 Mon Sep 17 00:00:00 2001 From: Claire Date: Tue, 20 Jan 2026 15:14:45 +0100 Subject: [PATCH 4/5] Merge commit from fork * Add limit on inbox payload size The 1MB limit is consistent with the limit we use when fetching remote resources * Add limit to number of options from federated polls * Add a limit to the number of federated profile fields * Add limit on federated username length * Add hard limits for federated display name and account bio * Add hard limits for `alsoKnownAs` and `attributionDomains` * Add hard limit on federated custom emoji shortcode * Highlight most destructive limits and expand on their reasoning --- FEDERATION.md | 19 +++++++++++++++++++ .../activitypub/inboxes_controller.rb | 5 +++++ app/lib/activitypub/activity.rb | 1 + app/lib/activitypub/parser/poll_parser.rb | 6 +++++- app/models/account.rb | 9 ++++++++- app/models/custom_emoji.rb | 5 ++++- .../activitypub/process_account_service.rb | 14 +++++++++----- 7 files changed, 51 insertions(+), 8 deletions(-) diff --git a/FEDERATION.md b/FEDERATION.md index 2819fa935a..c98f5924cf 100644 --- a/FEDERATION.md +++ b/FEDERATION.md @@ -47,3 +47,22 @@ Mastodon requires all `POST` requests to be signed, and MAY require `GET` reques ### Additional documentation - [Mastodon documentation](https://docs.joinmastodon.org/) + +## Size limits + +Mastodon imposes a few hard limits on federated content. +These limits are intended to be very generous and way above what the Mastodon user experience is optimized for, so as to accomodate future changes and unusual or unforeseen usage patterns, while still providing some limits for performance reasons. +The following table attempts to summary those limits. + +| Limited property | Size limit | Consequence of exceeding the limit | +| ------------------------------------------------------------- | ---------- | ---------------------------------- | +| Serialized JSON-LD | 1MB | **Activity is rejected/dropped** | +| Profile fields (actor `PropertyValue` attachments) name/value | 2047 | Field name/value is truncated | +| Number of profile fields (actor `PropertyValue` attachments) | 50 | Fields list is truncated | +| Poll options (number of `anyOf`/`oneOf` in a `Question`) | 500 | Items list is truncated | +| Account username (actor `preferredUsername`) length | 2048 | **Actor will be rejected** | +| Account display name (actor `name`) length | 2048 | Display name will be truncated | +| Account note (actor `summary`) length | 20kB | Account note will be truncated | +| Account `attributionDomains` | 256 | List will be truncated | +| Account aliases (actor `alsoKnownAs`) | 256 | List will be truncated | +| Custom emoji shortcode (`Emoji` `name`) | 2048 | Emoji will be rejected | diff --git a/app/controllers/activitypub/inboxes_controller.rb b/app/controllers/activitypub/inboxes_controller.rb index 49cfc8ad1c..3d910b4e79 100644 --- a/app/controllers/activitypub/inboxes_controller.rb +++ b/app/controllers/activitypub/inboxes_controller.rb @@ -3,6 +3,7 @@ class ActivityPub::InboxesController < ActivityPub::BaseController include JsonLdHelper + before_action :skip_large_payload before_action :skip_unknown_actor_activity before_action :require_actor_signature! skip_before_action :authenticate_user! @@ -16,6 +17,10 @@ class ActivityPub::InboxesController < ActivityPub::BaseController private + def skip_large_payload + head 413 if request.content_length > ActivityPub::Activity::MAX_JSON_SIZE + end + def skip_unknown_actor_activity head 202 if unknown_affected_account? end diff --git a/app/lib/activitypub/activity.rb b/app/lib/activitypub/activity.rb index f5b55fff6a..f86bd3211a 100644 --- a/app/lib/activitypub/activity.rb +++ b/app/lib/activitypub/activity.rb @@ -5,6 +5,7 @@ class ActivityPub::Activity include Redisable include Lockable + MAX_JSON_SIZE = 1.megabyte SUPPORTED_TYPES = %w(Note Question).freeze CONVERTED_TYPES = %w(Image Audio Video Article Page Event).freeze diff --git a/app/lib/activitypub/parser/poll_parser.rb b/app/lib/activitypub/parser/poll_parser.rb index 758c03f07e..d43eaf6cfb 100644 --- a/app/lib/activitypub/parser/poll_parser.rb +++ b/app/lib/activitypub/parser/poll_parser.rb @@ -3,6 +3,10 @@ class ActivityPub::Parser::PollParser include JsonLdHelper + # Limit the number of items for performance purposes. + # We truncate rather than error out to avoid missing the post entirely. + MAX_ITEMS = 500 + def initialize(json) @json = json end @@ -48,6 +52,6 @@ class ActivityPub::Parser::PollParser private def items - @json['anyOf'] || @json['oneOf'] + (@json['anyOf'] || @json['oneOf'])&.take(MAX_ITEMS) end end diff --git a/app/models/account.rb b/app/models/account.rb index a3f724dc5f..234d35ad08 100644 --- a/app/models/account.rb +++ b/app/models/account.rb @@ -76,6 +76,13 @@ class Account < ApplicationRecord DISPLAY_NAME_LENGTH_LIMIT = 30 NOTE_LENGTH_LIMIT = 500 + # Hard limits for federated content + USERNAME_LENGTH_HARD_LIMIT = 2048 + DISPLAY_NAME_LENGTH_HARD_LIMIT = 2048 + NOTE_LENGTH_HARD_LIMIT = 20.kilobytes + ATTRIBUTION_DOMAINS_HARD_LIMIT = 256 + ALSO_KNOWN_AS_HARD_LIMIT = 256 + AUTOMATED_ACTOR_TYPES = %w(Application Service).freeze include Attachmentable # Load prior to Avatar & Header concerns @@ -103,7 +110,7 @@ class Account < ApplicationRecord validates_with UniqueUsernameValidator, if: -> { will_save_change_to_username? } # Remote user validations, also applies to internal actors - validates :username, format: { with: USERNAME_ONLY_RE }, if: -> { (!local? || actor_type == 'Application') && will_save_change_to_username? } + validates :username, format: { with: USERNAME_ONLY_RE }, length: { maximum: USERNAME_LENGTH_HARD_LIMIT }, if: -> { (!local? || actor_type == 'Application') && will_save_change_to_username? } # Remote user validations validates :uri, presence: true, unless: :local?, on: :create diff --git a/app/models/custom_emoji.rb b/app/models/custom_emoji.rb index f1423bc2fa..96abcc16c8 100644 --- a/app/models/custom_emoji.rb +++ b/app/models/custom_emoji.rb @@ -26,6 +26,8 @@ class CustomEmoji < ApplicationRecord LIMIT = 256.kilobytes MINIMUM_SHORTCODE_SIZE = 2 + MAX_SHORTCODE_SIZE = 128 + MAX_FEDERATED_SHORTCODE_SIZE = 2048 SHORTCODE_RE_FRAGMENT = '[a-zA-Z0-9_]{2,}' @@ -45,7 +47,8 @@ class CustomEmoji < ApplicationRecord normalizes :domain, with: ->(domain) { domain.downcase } validates_attachment :image, content_type: { content_type: IMAGE_MIME_TYPES }, presence: true, size: { less_than: LIMIT } - validates :shortcode, uniqueness: { scope: :domain }, format: { with: SHORTCODE_ONLY_RE }, length: { minimum: MINIMUM_SHORTCODE_SIZE } + validates :shortcode, uniqueness: { scope: :domain }, format: { with: SHORTCODE_ONLY_RE }, length: { minimum: MINIMUM_SHORTCODE_SIZE, maximum: MAX_FEDERATED_SHORTCODE_SIZE } + validates :shortcode, length: { maximum: MAX_SHORTCODE_SIZE }, if: :local? scope :local, -> { where(domain: nil) } scope :remote, -> { where.not(domain: nil) } diff --git a/app/services/activitypub/process_account_service.rb b/app/services/activitypub/process_account_service.rb index e516582d43..0d7f58a15a 100644 --- a/app/services/activitypub/process_account_service.rb +++ b/app/services/activitypub/process_account_service.rb @@ -6,6 +6,7 @@ class ActivityPub::ProcessAccountService < BaseService include Redisable include Lockable + MAX_PROFILE_FIELDS = 50 SUBDOMAINS_RATELIMIT = 10 DISCOVERIES_PER_REQUEST = 400 @@ -122,15 +123,15 @@ class ActivityPub::ProcessAccountService < BaseService def set_immediate_attributes! @account.featured_collection_url = valid_collection_uri(@json['featured']) - @account.display_name = @json['name'] || '' - @account.note = @json['summary'] || '' + @account.display_name = (@json['name'] || '')[0...(Account::DISPLAY_NAME_LENGTH_HARD_LIMIT)] + @account.note = (@json['summary'] || '')[0...(Account::NOTE_LENGTH_HARD_LIMIT)] @account.locked = @json['manuallyApprovesFollowers'] || false @account.fields = property_values || {} - @account.also_known_as = as_array(@json['alsoKnownAs'] || []).map { |item| value_or_id(item) } + @account.also_known_as = as_array(@json['alsoKnownAs'] || []).take(Account::ALSO_KNOWN_AS_HARD_LIMIT).map { |item| value_or_id(item) } @account.discoverable = @json['discoverable'] || false @account.indexable = @json['indexable'] || false @account.memorial = @json['memorial'] || false - @account.attribution_domains = as_array(@json['attributionDomains'] || []).map { |item| value_or_id(item) } + @account.attribution_domains = as_array(@json['attributionDomains'] || []).take(Account::ATTRIBUTION_DOMAINS_HARD_LIMIT).map { |item| value_or_id(item) } end def set_fetchable_key! @@ -251,7 +252,10 @@ class ActivityPub::ProcessAccountService < BaseService def property_values return unless @json['attachment'].is_a?(Array) - as_array(@json['attachment']).select { |attachment| attachment['type'] == 'PropertyValue' }.map { |attachment| attachment.slice('name', 'value') } + as_array(@json['attachment']) + .select { |attachment| attachment['type'] == 'PropertyValue' } + .take(MAX_PROFILE_FIELDS) + .map { |attachment| attachment.slice('name', 'value') } end def mismatching_origin?(url) From fa553d848417ffe48f23794f7b798b87eb57477f Mon Sep 17 00:00:00 2001 From: Claire Date: Tue, 20 Jan 2026 15:53:53 +0100 Subject: [PATCH 5/5] Bump version to v4.3.18 (#37548) --- CHANGELOG.md | 17 +++++++++++++++++ docker-compose.yml | 6 +++--- lib/mastodon/version.rb | 2 +- 3 files changed, 21 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e1deb6eaac..b78f14626d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,23 @@ All notable changes to this project will be documented in this file. +## [4.3.18] - 2026-01-20 + +### Security + +- Fix missing limits on various federated properties [GHSA-gg8q-rcg7-p79g](https://github.com/mastodon/mastodon/security/advisories/GHSA-gg8q-rcg7-p79g) +- Fix remote user suspension bypass [GHSA-5h2f-wg8j-xqwp](https://github.com/mastodon/mastodon/security/advisories/GHSA-5h2f-wg8j-xqwp) +- Fix missing length limits on some user-provided fields [GHSA-6x3w-9g92-gvf3](https://github.com/mastodon/mastodon/security/advisories/GHSA-6x3w-9g92-gvf3) +- Fix missing access check for push notification settings update [GHSA-f3q8-7vw3-69v4](https://github.com/mastodon/mastodon/security/advisories/GHSA-f3q8-7vw3-69v4) + +### Fixed + +- Fix `FeedManager#filter_from_home` error when handling a reblog of a deleted status (#37486 by @ClearlyClaire) +- Fix needlessly complicated SQL query in status batch removal (#37469 by @ClearlyClaire) +- Fix `Vary` parsing in cache control enforcement (#37426 by @MegaManSec) +- Fix thread-unsafe ActivityPub activity dispatch (#37423 by @MegaManSec) +- Fix SignatureParser accepting duplicate parameters in HTTP Signature header (#37375 by @shleeable) + ## [4.3.17] - 2026-01-07 ### Security diff --git a/docker-compose.yml b/docker-compose.yml index 673dabc08a..f27ddb1663 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -59,7 +59,7 @@ services: web: # You can uncomment the following line if you want to not use the prebuilt image, for example if you have local code changes # build: . - image: ghcr.io/mastodon/mastodon:v4.3.17 + image: ghcr.io/mastodon/mastodon:v4.3.18 restart: always env_file: .env.production command: bundle exec puma -C config/puma.rb @@ -83,7 +83,7 @@ services: # build: # dockerfile: ./streaming/Dockerfile # context: . - image: ghcr.io/mastodon/mastodon-streaming:v4.3.17 + image: ghcr.io/mastodon/mastodon-streaming:v4.3.18 restart: always env_file: .env.production command: node ./streaming/index.js @@ -102,7 +102,7 @@ services: sidekiq: # You can uncomment the following line if you want to not use the prebuilt image, for example if you have local code changes # build: . - image: ghcr.io/mastodon/mastodon:v4.3.17 + image: ghcr.io/mastodon/mastodon:v4.3.18 restart: always env_file: .env.production command: bundle exec sidekiq diff --git a/lib/mastodon/version.rb b/lib/mastodon/version.rb index 3f322d4fe9..afa93e8cf7 100644 --- a/lib/mastodon/version.rb +++ b/lib/mastodon/version.rb @@ -13,7 +13,7 @@ module Mastodon end def patch - 17 + 18 end def default_prerelease