CVE-2022-46177 (Ruby, published 2023-01-05T20:15Z)
Description: value: Discourse is an option source discussion platform. Prior to version 2.8.14 on the `stable` branch and version 3.0.0.beta16 on the `beta` and `tests-passed` branches, when a user requests for a password reset link email, then changes their primary email, the old reset email is still valid. When the old reset email is used to reset the password, the Discourse account's primary email would be re-linked to the old email. If the old email address is compromised or has transferred ownership, this leads to an account takeover. This is however mitigated by the SiteSetting `email_token_valid_hours` which is currently 48 hours. Users should upgrade to versions 2.8.14 or 3.0.0.beta15 to receive a patch. As a workaround, lower `email_token_valid_hours ` as needed.
Severity: nan (CVSS v3 base score: 8.1)
Weakness: CWE-613 \u2014 Insufficient Session Expiration
According to WASC, "Insufficient Session Expiration is when a web site permits an attacker to reuse old session credentials or session IDs for authorization."
Repository: https://github.com/discourse/discourse
Files changed:
app/models/user_email.rb
spec/requests/users_controller_spec.rb
spec/requests/users_email_controller_spec.rb
Commit message: SECURITY: Delete email tokens when a user's email is changed or deleted (#19735)

Co-authored-by: OsamaSayegh <asooomaasoooma90@gmail.com>

Vulnerable code:
    it "can destroy associated email tokens" do
      expect { updater.change_to(new_email) }
        .to change { user1.email_tokens.count }.by(1)
      expect { delete "/u/#{user1.username}/preferences/email.json", params: { email: new_email } }
        .to change { user1.email_tokens.count }.by(-1)
      expect(user1.email_tokens.first.email).to eq(user1.email)
        token.reload
        expect(token.expired?).to eq(true)
        token.reload
        expect(token.expired?).to eq(true)

Fixed code:
  before_save ->() { destroy_email_tokens(self.email_was) }, if: :will_save_change_to_email?

  after_destroy { destroy_email_tokens(self.email) }


  def destroy_email_tokens(email)
    EmailToken.where(email: email).destroy_all
  end
    it "destroys associated email tokens and email change requests" do
      updater.change_to(new_email)
      email_token = updater.change_req.new_email_token
      expect(email_token).to be_present
      delete "/u/#{user1.username}/preferences/email.json", params: { email: new_email }
      expect(EmailToken.find_by(id: email_token.id)).to eq(nil)
      expect(EmailChangeRequest.find_by(id: updater.change_req.id)).to eq(nil)
        expect(EmailToken.find_by(id: token.id)).to eq(nil)
        expect(EmailToken.find_by(id: token.id)).to eq(nil)

    it "destroys email tokens associated with the old email after the new email is confirmed" do
      SiteSetting.enable_secondary_emails = true

      email_token = user.email_tokens.create!(email: user.email, scope: EmailToken.scopes[:password_reset])

      updater = EmailUpdater.new(guardian: user.guardian, user: user)
      updater.change_to('bubblegum@adventuretime.ooo')

      sign_in(user)
      put "/u/confirm-new-email", params: {
        token: "#{updater.change_req.new_email_token.token}"
      }

      new_password = SecureRandom.hex
      put "/u/password-reset/#{email_token.token}.json", params: {
        password: new_password
      }
      expect(response.parsed_body["success"]).to eq(false)
      expect(response.parsed_body["message"]).to eq(I18n.t("password_reset.no_token", base_url: Discourse.base_url))
      expect(user.reload.confirm_password?(new_password)).to eq(false)
    end