CVE-2024-25618 (Ruby, published 2024-02-14T21:15Z)
Description: value: Mastodon is a free, open-source social network server based on ActivityPub. Mastodon allows new identities from configured authentication providers (CAS, SAML, OIDC) to attach to existing local users with the same e-mail address. This results in a possible account takeover if the authentication provider allows changing the e-mail address or multiple authentication providers are configured. When a user logs in through an external authentication provider for the first time, Mastodon checks the e-mail address passed by the provider to find an existing account. However, using the e-mail address alone means that if the authentication provider allows changing the e-mail address of an account, the Mastodon account can immediately be hijacked. All users logging in through external authentication providers are affected. The severity is medium, as it also requires the external authentication provider to misbehave. However, some well-known OIDC providers (like Microsoft Azure) make it very easy to accidentally allow unverified e-mail changes. Moreover, OpenID Connect also allows dynamic client registration. This issue has been addressed in versions 4.2.6, 4.1.14, 4.0.14, and 3.5.18. Users are advised to upgrade. There are no known workarounds for this vulnerability.
Severity: nan (CVSS v3 base score: )
Weakness: CWE-287 \u2014 Improper Authentication
When an actor claims to have a given identity, the product does not prove or insufficiently proves that the claim is correct.
Repository: https://github.com/mastodon/mastodon
Files changed:
app/controllers/auth/omniauth_callbacks_controller.rb
app/models/concerns/user/omniauthable.rb
app/models/identity.rb
spec/models/identity_spec.rb
spec/requests/omniauth_callbacks_spec.rb
Commit message: 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

Vulnerable code:
      @user = User.find_for_oauth(request.env['omniauth.auth'], current_user)
    def find_for_oauth(auth, signed_in_resource = nil)
      identity = Identity.find_for_oauth(auth)
      user ||= create_for_oauth(auth)
    def create_for_oauth(auth)
      # Check if the user exists with provided email. 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?
    private
  def self.find_for_oauth(auth)
  describe '.find_for_oauth' do
      described_class.find_for_oauth(auth)
      expect(described_class.find_for_oauth(auth)).to be_instance_of described_class
        allow(User).to receive(:find_for_oauth).and_return(User.new)

Fixed code:
      @user = User.find_for_omniauth(request.env['omniauth.auth'], current_user)
    def find_for_omniauth(auth, signed_in_resource = nil)
      identity = Identity.find_for_omniauth(auth)
      user ||= reattach_for_auth(auth)
      user ||= create_for_auth(auth)
    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

      email, email_is_verified = email_from_auth(auth)
    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 self.find_for_omniauth(auth)
  describe '.find_for_omniauth' do
      described_class.find_for_omniauth(auth)
      expect(described_class.find_for_omniauth(auth)).to be_instance_of described_class
        allow(User).to receive(:find_for_omniauth).and_return(User.new)