From 51b1a49834f1de155bef146b1fbac5afd9ee4412 Mon Sep 17 00:00:00 2001 From: Claire Date: Mon, 13 Oct 2025 10:46:56 +0200 Subject: [PATCH 1/7] Update dependency `rack` --- Gemfile.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Gemfile.lock b/Gemfile.lock index cc2164765c..1787f57313 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -619,7 +619,7 @@ GEM activesupport (>= 3.0.0) raabro (1.4.0) racc (1.8.1) - rack (2.2.19) + rack (2.2.20) rack-attack (6.7.0) rack (>= 1.0, < 4) rack-cors (2.0.2) From 233f7570b34c169b9af6d018eceed45ccb552ebe Mon Sep 17 00:00:00 2001 From: Claire Date: Mon, 13 Oct 2025 10:50:04 +0200 Subject: [PATCH 2/7] Update dependency `openssl` --- Gemfile.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Gemfile.lock b/Gemfile.lock index 1787f57313..87aa76772e 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -492,7 +492,7 @@ GEM validate_email validate_url webfinger (~> 1.2) - openssl (3.2.0) + openssl (3.2.2) openssl-signature_algorithm (1.3.0) openssl (> 2.0) opentelemetry-api (1.4.0) From 50b586ef024d7046937ae62b6c8c0211dbda6f49 Mon Sep 17 00:00:00 2001 From: Claire Date: Mon, 13 Oct 2025 14:19:14 +0200 Subject: [PATCH 3/7] Merge commit from fork * Streaming: Ensure disabled users cannot connect to streaming * Streaming: Disconnect when the user is disabled --------- Co-authored-by: Emelia Smith --- app/models/user.rb | 4 ++++ spec/models/user_spec.rb | 11 +++++++---- spec/system/streaming/streaming_spec.rb | 24 ++++++++++++++++++++++++ streaming/index.js | 2 +- 4 files changed, 36 insertions(+), 5 deletions(-) diff --git a/app/models/user.rb b/app/models/user.rb index fa1a275bbd..d8517f85c1 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -183,6 +183,10 @@ class User < ApplicationRecord def disable! update!(disabled: true) + + # This terminates all connections for the given account with the streaming + # server: + redis.publish("timeline:system:#{account.id}", Oj.dump(event: :kill)) end def enable! diff --git a/spec/models/user_spec.rb b/spec/models/user_spec.rb index 4393be5a4e..d61d51c117 100644 --- a/spec/models/user_spec.rb +++ b/spec/models/user_spec.rb @@ -432,12 +432,15 @@ RSpec.describe User do let(:current_sign_in_at) { Time.zone.now } - before do - user.disable! - end - it 'disables user' do + allow(redis).to receive(:publish) + + user.disable! + expect(user).to have_attributes(disabled: true) + + expect(redis) + .to have_received(:publish).with("timeline:system:#{user.account.id}", Oj.dump(event: :kill)).once end end diff --git a/spec/system/streaming/streaming_spec.rb b/spec/system/streaming/streaming_spec.rb index c12bd1b18f..7370033890 100644 --- a/spec/system/streaming/streaming_spec.rb +++ b/spec/system/streaming/streaming_spec.rb @@ -74,4 +74,28 @@ RSpec.describe 'Streaming', :inline_jobs, :streaming do expect(streaming_client.open?).to be(false) end end + + context 'with a disabled user account' do + before do + user.disable! + end + + it 'receives an 401 unauthorized error when trying to connect' do + streaming_client.connect + + expect(streaming_client.status).to eq(401) + expect(streaming_client.open?).to be(false) + end + end + + context 'when the user account is disabled whilst connected' do + it 'terminates the connection for the user' do + streaming_client.connect + + user.disable! + + expect(streaming_client.wait_for(:closed).code).to be(1000) + expect(streaming_client.open?).to be(false) + end + end end diff --git a/streaming/index.js b/streaming/index.js index 22c606bea3..7068f4970c 100644 --- a/streaming/index.js +++ b/streaming/index.js @@ -354,7 +354,7 @@ const startServer = async () => { * @returns {Promise} */ const accountFromToken = async (token, req) => { - const result = await pgPool.query('SELECT oauth_access_tokens.id, oauth_access_tokens.resource_owner_id, users.account_id, users.chosen_languages, oauth_access_tokens.scopes FROM oauth_access_tokens INNER JOIN users ON oauth_access_tokens.resource_owner_id = users.id WHERE oauth_access_tokens.token = $1 AND oauth_access_tokens.revoked_at IS NULL LIMIT 1', [token]); + const result = await pgPool.query('SELECT oauth_access_tokens.id, oauth_access_tokens.resource_owner_id, users.account_id, users.chosen_languages, oauth_access_tokens.scopes FROM oauth_access_tokens INNER JOIN users ON oauth_access_tokens.resource_owner_id = users.id WHERE oauth_access_tokens.token = $1 AND oauth_access_tokens.revoked_at IS NULL AND users.disabled IS FALSE LIMIT 1', [token]); if (result.rows.length === 0) { throw new AuthenticationError('Invalid access token'); From 032aa9eb6830543f596869d3dc1651f14d6d10f7 Mon Sep 17 00:00:00 2001 From: Claire Date: Mon, 13 Oct 2025 14:20:23 +0200 Subject: [PATCH 4/7] Merge commit from fork * Ensure tootctl revokes sessions, access tokens and web push subscriptions * Fix test coverage --------- Co-authored-by: Emelia Smith --- app/models/user.rb | 13 +++++++++---- lib/mastodon/cli/accounts.rb | 7 +++++-- spec/lib/mastodon/cli/accounts_spec.rb | 13 +++++++++++-- 3 files changed, 25 insertions(+), 8 deletions(-) diff --git a/app/models/user.rb b/app/models/user.rb index d8517f85c1..c75911ceeb 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -364,17 +364,22 @@ class User < ApplicationRecord end def reset_password! + # First, change password to something random, this revokes sessions and on-going access: + change_password!(SecureRandom.hex) + + # Finally, send a reset password prompt to the user + send_reset_password_instructions + end + + def change_password!(new_password) # First, change password to something random and deactivate all sessions transaction do - update(password: SecureRandom.hex) + update(password: new_password) session_activations.destroy_all end # Then, remove all authorized applications and connected push subscriptions revoke_access! - - # Finally, send a reset password prompt to the user - send_reset_password_instructions end protected diff --git a/lib/mastodon/cli/accounts.rb b/lib/mastodon/cli/accounts.rb index 08a28e5f5c..712a509045 100644 --- a/lib/mastodon/cli/accounts.rb +++ b/lib/mastodon/cli/accounts.rb @@ -159,14 +159,17 @@ module Mastodon::CLI user.role_id = nil end - password = SecureRandom.hex if options[:reset_password] - user.password = password if options[:reset_password] user.email = options[:email] if options[:email] user.disabled = false if options[:enable] user.disabled = true if options[:disable] user.approved = true if options[:approve] user.otp_required_for_login = false if options[:disable_2fa] + # Password changes are a little different, as we also need to ensure + # sessions, subscriptions, and access tokens are revoked after changing: + password = SecureRandom.hex if options[:reset_password] + user.change_password!(password) if options[:reset_password] + if user.save user.confirm if options[:confirm] diff --git a/spec/lib/mastodon/cli/accounts_spec.rb b/spec/lib/mastodon/cli/accounts_spec.rb index f6cc28297a..53392864ff 100644 --- a/spec/lib/mastodon/cli/accounts_spec.rb +++ b/spec/lib/mastodon/cli/accounts_spec.rb @@ -335,11 +335,20 @@ RSpec.describe Mastodon::CLI::Accounts do context 'with --reset-password option' do let(:options) { { reset_password: true } } + let(:user) { Fabricate(:user, password: original_password) } + let(:original_password) { 'foobar12345' } + let(:new_password) { 'new_password12345' } + it 'returns a new password for the user' do - allow(SecureRandom).to receive(:hex).and_return('new_password') + allow(SecureRandom).to receive(:hex).and_return(new_password) + allow(Account).to receive(:find_local).and_return(user.account) + allow(user).to receive(:change_password!).and_call_original expect { subject } - .to output_results('new_password') + .to output_results(new_password) + + expect(user).to have_received(:change_password!).with(new_password) + expect(user.reload).to_not be_external_or_valid_password(original_password) end end From aa1d3825cd1b9e8a9c0400f3f68fc2b60ed11c40 Mon Sep 17 00:00:00 2001 From: Emelia Smith Date: Mon, 13 Oct 2025 14:20:57 +0200 Subject: [PATCH 5/7] Merge commit from fork --- streaming/index.js | 25 ++++++++----------------- 1 file changed, 8 insertions(+), 17 deletions(-) diff --git a/streaming/index.js b/streaming/index.js index 7068f4970c..456d4488d1 100644 --- a/streaming/index.js +++ b/streaming/index.js @@ -78,16 +78,6 @@ const parseJSON = (json, req) => { } }; -const PUBLIC_CHANNELS = [ - 'public', - 'public:media', - 'public:local', - 'public:local:media', - 'public:remote', - 'public:remote:media', - 'hashtag', - 'hashtag:local', -]; // Used for priming the counters/gauges for the various metrics that are // per-channel @@ -97,7 +87,14 @@ const CHANNEL_NAMES = [ 'user:notification', 'list', 'direct', - ...PUBLIC_CHANNELS + 'public', + 'public:media', + 'public:local', + 'public:local:media', + 'public:remote', + 'public:remote:media', + 'hashtag', + 'hashtag:local' ]; const startServer = async () => { @@ -433,12 +430,6 @@ const startServer = async () => { const checkScopes = (req, logger, channelName) => new Promise((resolve, reject) => { logger.debug(`Checking OAuth scopes for ${channelName}`); - // When accessing public channels, no scopes are needed - if (channelName && PUBLIC_CHANNELS.includes(channelName)) { - resolve(); - return; - } - // The `read` scope has the highest priority, if the token has it // then it can access all streams const requiredScopes = ['read']; From 81ed241061fd7b0158edd98b095df84559b65120 Mon Sep 17 00:00:00 2001 From: Claire Date: Mon, 13 Oct 2025 15:49:30 +0200 Subject: [PATCH 6/7] Fix streaming still being authorized for suspended accounts (#36450) --- app/models/concerns/account/suspensions.rb | 4 ++++ spec/system/streaming/streaming_spec.rb | 24 ++++++++++++++++++++++ streaming/index.js | 2 +- 3 files changed, 29 insertions(+), 1 deletion(-) diff --git a/app/models/concerns/account/suspensions.rb b/app/models/concerns/account/suspensions.rb index c981fb5a29..4c9ca593ad 100644 --- a/app/models/concerns/account/suspensions.rb +++ b/app/models/concerns/account/suspensions.rb @@ -32,6 +32,10 @@ module Account::Suspensions update!(suspended_at: date, suspension_origin: origin) create_canonical_email_block! if block_email end + + # This terminates all connections for the given account with the streaming + # server: + redis.publish("timeline:system:#{id}", Oj.dump(event: :kill)) if local? end def unsuspend! diff --git a/spec/system/streaming/streaming_spec.rb b/spec/system/streaming/streaming_spec.rb index 7370033890..f5d3ba1142 100644 --- a/spec/system/streaming/streaming_spec.rb +++ b/spec/system/streaming/streaming_spec.rb @@ -98,4 +98,28 @@ RSpec.describe 'Streaming', :inline_jobs, :streaming do expect(streaming_client.open?).to be(false) end end + + context 'with a suspended user account' do + before do + user.account.suspend! + end + + it 'receives an 401 unauthorized error when trying to connect' do + streaming_client.connect + + expect(streaming_client.status).to eq(401) + expect(streaming_client.open?).to be(false) + end + end + + context 'when the user account is suspended whilst connected' do + it 'terminates the connection for the user' do + streaming_client.connect + + user.account.suspend! + + expect(streaming_client.wait_for(:closed).code).to be(1000) + expect(streaming_client.open?).to be(false) + end + end end diff --git a/streaming/index.js b/streaming/index.js index 456d4488d1..24dc1fd558 100644 --- a/streaming/index.js +++ b/streaming/index.js @@ -351,7 +351,7 @@ const startServer = async () => { * @returns {Promise} */ const accountFromToken = async (token, req) => { - const result = await pgPool.query('SELECT oauth_access_tokens.id, oauth_access_tokens.resource_owner_id, users.account_id, users.chosen_languages, oauth_access_tokens.scopes FROM oauth_access_tokens INNER JOIN users ON oauth_access_tokens.resource_owner_id = users.id WHERE oauth_access_tokens.token = $1 AND oauth_access_tokens.revoked_at IS NULL AND users.disabled IS FALSE LIMIT 1', [token]); + const result = await pgPool.query('SELECT oauth_access_tokens.id, oauth_access_tokens.resource_owner_id, users.account_id, users.chosen_languages, oauth_access_tokens.scopes FROM oauth_access_tokens INNER JOIN users ON oauth_access_tokens.resource_owner_id = users.id INNER JOIN accounts ON accounts.id = users.account_id WHERE oauth_access_tokens.token = $1 AND oauth_access_tokens.revoked_at IS NULL AND users.disabled IS FALSE AND accounts.suspended_at IS NULL LIMIT 1', [token]); if (result.rows.length === 0) { throw new AuthenticationError('Invalid access token'); From 6dee9a12d2c2c3671ad9f4bc035f050a7c9549c5 Mon Sep 17 00:00:00 2001 From: Claire Date: Mon, 13 Oct 2025 15:49:51 +0200 Subject: [PATCH 7/7] Bump version to v4.3.14 (#36445) --- CHANGELOG.md | 13 +++++++++++++ docker-compose.yml | 6 +++--- lib/mastodon/version.rb | 2 +- 3 files changed, 17 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9270013621..20e12f3ccd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,19 @@ All notable changes to this project will be documented in this file. +## [4.3.14] - 2025-10-13 + +### Security + +- Update dependencies `rack` and `uri` +- Fix streaming server connection not being closed on user suspension (by @ThisIsMissEm, [GHSA-r2fh-jr9c-9pxh](https://github.com/mastodon/mastodon/security/advisories/GHSA-r2fh-jr9c-9pxh)) +- Fix password change through admin CLI not invalidating existing sessions and access tokens (by @ThisIsMissEm, [GHSA-f3q3-rmf7-9655](https://github.com/mastodon/mastodon/security/advisories/GHSA-f3q3-rmf7-9655)) +- Fix streaming server allowing access to public timelines even without the `read` or `read:statuses` OAuth scopes (by @ThisIsMissEm, [GHSA-7gwh-mw97-qjgp](https://github.com/mastodon/mastodon/security/advisories/GHSA-7gwh-mw97-qjgp)) + +### Fixed + +- Fix redirect to external object when URL is missing or malformed (#36347 by @ClearlyClaire) + ## [4.3.13] - 2025-09-23 ### Security diff --git a/docker-compose.yml b/docker-compose.yml index 2005e759bc..aa85aa066f 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.13 + image: ghcr.io/mastodon/mastodon:v4.3.14 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.13 + image: ghcr.io/mastodon/mastodon-streaming:v4.3.14 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.13 + image: ghcr.io/mastodon/mastodon:v4.3.14 restart: always env_file: .env.production command: bundle exec sidekiq diff --git a/lib/mastodon/version.rb b/lib/mastodon/version.rb index a16cf9118b..33e5922b71 100644 --- a/lib/mastodon/version.rb +++ b/lib/mastodon/version.rb @@ -13,7 +13,7 @@ module Mastodon end def patch - 13 + 14 end def default_prerelease