mirror of
https://github.com/glitch-soc/mastodon.git
synced 2025-12-13 07:49:29 +00:00
Merge commit '6dee9a12d2c2c3671ad9f4bc035f050a7c9549c5' into glitch-soc/merge-4.3
This commit is contained in:
13
CHANGELOG.md
13
CHANGELOG.md
@@ -2,6 +2,19 @@
|
|||||||
|
|
||||||
All notable changes to this project will be documented in this file.
|
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
|
## [4.3.13] - 2025-09-23
|
||||||
|
|
||||||
### Security
|
### Security
|
||||||
|
|||||||
@@ -492,7 +492,7 @@ GEM
|
|||||||
validate_email
|
validate_email
|
||||||
validate_url
|
validate_url
|
||||||
webfinger (~> 1.2)
|
webfinger (~> 1.2)
|
||||||
openssl (3.2.0)
|
openssl (3.2.2)
|
||||||
openssl-signature_algorithm (1.3.0)
|
openssl-signature_algorithm (1.3.0)
|
||||||
openssl (> 2.0)
|
openssl (> 2.0)
|
||||||
opentelemetry-api (1.4.0)
|
opentelemetry-api (1.4.0)
|
||||||
@@ -619,7 +619,7 @@ GEM
|
|||||||
activesupport (>= 3.0.0)
|
activesupport (>= 3.0.0)
|
||||||
raabro (1.4.0)
|
raabro (1.4.0)
|
||||||
racc (1.8.1)
|
racc (1.8.1)
|
||||||
rack (2.2.19)
|
rack (2.2.20)
|
||||||
rack-attack (6.7.0)
|
rack-attack (6.7.0)
|
||||||
rack (>= 1.0, < 4)
|
rack (>= 1.0, < 4)
|
||||||
rack-cors (2.0.2)
|
rack-cors (2.0.2)
|
||||||
|
|||||||
@@ -32,6 +32,10 @@ module Account::Suspensions
|
|||||||
update!(suspended_at: date, suspension_origin: origin)
|
update!(suspended_at: date, suspension_origin: origin)
|
||||||
create_canonical_email_block! if block_email
|
create_canonical_email_block! if block_email
|
||||||
end
|
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
|
end
|
||||||
|
|
||||||
def unsuspend!
|
def unsuspend!
|
||||||
|
|||||||
@@ -183,6 +183,10 @@ class User < ApplicationRecord
|
|||||||
|
|
||||||
def disable!
|
def disable!
|
||||||
update!(disabled: true)
|
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
|
end
|
||||||
|
|
||||||
def enable!
|
def enable!
|
||||||
@@ -360,17 +364,22 @@ class User < ApplicationRecord
|
|||||||
end
|
end
|
||||||
|
|
||||||
def reset_password!
|
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
|
# First, change password to something random and deactivate all sessions
|
||||||
transaction do
|
transaction do
|
||||||
update(password: SecureRandom.hex)
|
update(password: new_password)
|
||||||
session_activations.destroy_all
|
session_activations.destroy_all
|
||||||
end
|
end
|
||||||
|
|
||||||
# Then, remove all authorized applications and connected push subscriptions
|
# Then, remove all authorized applications and connected push subscriptions
|
||||||
revoke_access!
|
revoke_access!
|
||||||
|
|
||||||
# Finally, send a reset password prompt to the user
|
|
||||||
send_reset_password_instructions
|
|
||||||
end
|
end
|
||||||
|
|
||||||
protected
|
protected
|
||||||
|
|||||||
@@ -59,7 +59,7 @@ services:
|
|||||||
web:
|
web:
|
||||||
# You can uncomment the following line if you want to not use the prebuilt image, for example if you have local code changes
|
# You can uncomment the following line if you want to not use the prebuilt image, for example if you have local code changes
|
||||||
# build: .
|
# build: .
|
||||||
image: ghcr.io/glitch-soc/mastodon:v4.3.13
|
image: ghcr.io/glitch-soc/mastodon:v4.3.14
|
||||||
restart: always
|
restart: always
|
||||||
env_file: .env.production
|
env_file: .env.production
|
||||||
command: bundle exec puma -C config/puma.rb
|
command: bundle exec puma -C config/puma.rb
|
||||||
@@ -83,7 +83,7 @@ services:
|
|||||||
# build:
|
# build:
|
||||||
# dockerfile: ./streaming/Dockerfile
|
# dockerfile: ./streaming/Dockerfile
|
||||||
# context: .
|
# context: .
|
||||||
image: ghcr.io/glitch-soc/mastodon-streaming:v4.3.13
|
image: ghcr.io/glitch-soc/mastodon-streaming:v4.3.14
|
||||||
restart: always
|
restart: always
|
||||||
env_file: .env.production
|
env_file: .env.production
|
||||||
command: node ./streaming/index.js
|
command: node ./streaming/index.js
|
||||||
@@ -102,7 +102,7 @@ services:
|
|||||||
sidekiq:
|
sidekiq:
|
||||||
# You can uncomment the following line if you want to not use the prebuilt image, for example if you have local code changes
|
# You can uncomment the following line if you want to not use the prebuilt image, for example if you have local code changes
|
||||||
# build: .
|
# build: .
|
||||||
image: ghcr.io/glitch-soc/mastodon:v4.3.13
|
image: ghcr.io/glitch-soc/mastodon:v4.3.14
|
||||||
restart: always
|
restart: always
|
||||||
env_file: .env.production
|
env_file: .env.production
|
||||||
command: bundle exec sidekiq
|
command: bundle exec sidekiq
|
||||||
|
|||||||
@@ -159,14 +159,17 @@ module Mastodon::CLI
|
|||||||
user.role_id = nil
|
user.role_id = nil
|
||||||
end
|
end
|
||||||
|
|
||||||
password = SecureRandom.hex if options[:reset_password]
|
|
||||||
user.password = password if options[:reset_password]
|
|
||||||
user.email = options[:email] if options[:email]
|
user.email = options[:email] if options[:email]
|
||||||
user.disabled = false if options[:enable]
|
user.disabled = false if options[:enable]
|
||||||
user.disabled = true if options[:disable]
|
user.disabled = true if options[:disable]
|
||||||
user.approved = true if options[:approve]
|
user.approved = true if options[:approve]
|
||||||
user.otp_required_for_login = false if options[:disable_2fa]
|
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
|
if user.save
|
||||||
user.confirm if options[:confirm]
|
user.confirm if options[:confirm]
|
||||||
|
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ module Mastodon
|
|||||||
end
|
end
|
||||||
|
|
||||||
def patch
|
def patch
|
||||||
13
|
14
|
||||||
end
|
end
|
||||||
|
|
||||||
def default_prerelease
|
def default_prerelease
|
||||||
|
|||||||
@@ -335,11 +335,20 @@ RSpec.describe Mastodon::CLI::Accounts do
|
|||||||
context 'with --reset-password option' do
|
context 'with --reset-password option' do
|
||||||
let(:options) { { reset_password: true } }
|
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
|
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 }
|
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
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@@ -432,12 +432,15 @@ RSpec.describe User do
|
|||||||
|
|
||||||
let(:current_sign_in_at) { Time.zone.now }
|
let(:current_sign_in_at) { Time.zone.now }
|
||||||
|
|
||||||
before do
|
|
||||||
user.disable!
|
|
||||||
end
|
|
||||||
|
|
||||||
it 'disables user' do
|
it 'disables user' do
|
||||||
|
allow(redis).to receive(:publish)
|
||||||
|
|
||||||
|
user.disable!
|
||||||
|
|
||||||
expect(user).to have_attributes(disabled: true)
|
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
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@@ -74,4 +74,52 @@ RSpec.describe 'Streaming', :inline_jobs, :streaming do
|
|||||||
expect(streaming_client.open?).to be(false)
|
expect(streaming_client.open?).to be(false)
|
||||||
end
|
end
|
||||||
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
|
||||||
|
|
||||||
|
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
|
end
|
||||||
|
|||||||
@@ -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
|
// Used for priming the counters/gauges for the various metrics that are
|
||||||
// per-channel
|
// per-channel
|
||||||
@@ -97,7 +87,14 @@ const CHANNEL_NAMES = [
|
|||||||
'user:notification',
|
'user:notification',
|
||||||
'list',
|
'list',
|
||||||
'direct',
|
'direct',
|
||||||
...PUBLIC_CHANNELS
|
'public',
|
||||||
|
'public:media',
|
||||||
|
'public:local',
|
||||||
|
'public:local:media',
|
||||||
|
'public:remote',
|
||||||
|
'public:remote:media',
|
||||||
|
'hashtag',
|
||||||
|
'hashtag:local'
|
||||||
];
|
];
|
||||||
|
|
||||||
const startServer = async () => {
|
const startServer = async () => {
|
||||||
@@ -354,7 +351,7 @@ const startServer = async () => {
|
|||||||
* @returns {Promise<ResolvedAccount>}
|
* @returns {Promise<ResolvedAccount>}
|
||||||
*/
|
*/
|
||||||
const accountFromToken = async (token, req) => {
|
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 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) {
|
if (result.rows.length === 0) {
|
||||||
throw new AuthenticationError('Invalid access token');
|
throw new AuthenticationError('Invalid access token');
|
||||||
@@ -433,12 +430,6 @@ const startServer = async () => {
|
|||||||
const checkScopes = (req, logger, channelName) => new Promise((resolve, reject) => {
|
const checkScopes = (req, logger, channelName) => new Promise((resolve, reject) => {
|
||||||
logger.debug(`Checking OAuth scopes for ${channelName}`);
|
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
|
// The `read` scope has the highest priority, if the token has it
|
||||||
// then it can access all streams
|
// then it can access all streams
|
||||||
const requiredScopes = ['read'];
|
const requiredScopes = ['read'];
|
||||||
|
|||||||
Reference in New Issue
Block a user