Compare commits

..
Author SHA1 Message Date
Vishnu Narayanan f16c7e59b8 perf: use update_columns for contact last_activity_at
Replace `sender.update(last_activity_at:)` with `update_columns` in
Message#update_contact_activity. This runs on every incoming message
(3.3M calls/day) and the full ActiveRecord update lifecycle is
unnecessary for a single timestamp write:

- Skips before_save :sync_contact_attributes (only relevant for
email/phone/identifier changes, not last_activity_at)
- Skips after_update_commit :dispatch_update_event which fires
CONTACT_UPDATED → ActionCable broadcasts, webhook deliveries,
and LeadSquared hooks on every incoming message with no
meaningful data change
- Eliminates 192K seconds/day of DB time from contact UPDATEs
that trigger full validation and callback chains
2026-02-17 18:40:36 +05:30
João Pedro Baza Garcia RodriguesandGitHub 4d362da9f0 fix: Prevent user enumeration on password reset endpoint (#13528)
## Description

The current password reset endpoint returns different HTTP status codes
and messages depending on whether the email exists in the system (200
for existing emails, 404 for non-existing ones). This allows attackers
to enumerate valid email addresses via the password reset form.

## Changes

### `app/controllers/devise_overrides/passwords_controller.rb`
- Removed the `if/else` branch that returned different responses based
on email existence
- Now always returns a generic `200 OK` response with the same message
regardless of whether the email exists
- Uses safe navigation operator (`&.`) to send reset instructions only
if the user exists

### `config/locales/en.yml`
- Consolidated `reset_password_success` and `reset_password_failure`
into a single generic `reset_password` key
- New message does not reveal whether the email exists in the system

## Security Impact
- **Before**: An attacker could determine if an email was registered by
observing the HTTP status code (200 vs 404) and response message
- **After**: All requests receive the same 200 response with a generic
message, preventing user enumeration

This follows [OWASP guidelines for authentication error
messages](https://cheatsheetseries.owasp.org/cheatsheets/Authentication_Cheat_Sheet.html#authentication-responses).

Fixes #13527
2026-02-13 13:45:40 +05:30
6 changed files with 12 additions and 46 deletions
@@ -6,12 +6,8 @@ class DeviseOverrides::PasswordsController < Devise::PasswordsController
def create
@user = User.from_email(params[:email])
if @user
@user.send_reset_password_instructions
build_response(I18n.t('messages.reset_password_success'), 200)
else
build_response(I18n.t('messages.reset_password_failure'), 404)
end
@user&.send_reset_password_instructions
build_response(I18n.t('messages.reset_password'), 200)
end
def update
+3 -11
View File
@@ -1,7 +1,5 @@
class ReplyMailbox < ApplicationMailbox
include IncomingEmailValidityHelper
attr_accessor :conversation, :processed_mail, :account
attr_accessor :conversation, :processed_mail
before_processing :find_conversation
@@ -9,17 +7,12 @@ class ReplyMailbox < ApplicationMailbox
# Return early if no conversation was found (e.g., notification emails, suspended accounts)
return unless @conversation
decorate_mail
unless incoming_email_from_valid_email?
Rails.logger.info "Email #{mail.message_id} rejected - failed incoming email validity checks"
return
end
# Wrap everything in a transaction to ensure atomicity
# This prevents orphan conversations if message/attachment creation fails
# and ensures idempotency on job retry (conversation won't be duplicated)
ActiveRecord::Base.transaction do
persist_conversation_if_needed
decorate_mail
create_message
add_attachments_to_message
end
@@ -29,7 +22,6 @@ class ReplyMailbox < ApplicationMailbox
def find_conversation
@conversation = Mailbox::ConversationFinder.new(mail).find
@account = @conversation&.account
# Log when email is rejected
Rails.logger.info "Email #{mail.message_id} rejected - no conversation found" unless @conversation
end
@@ -44,6 +36,6 @@ class ReplyMailbox < ApplicationMailbox
end
def decorate_mail
@processed_mail = MailPresenter.new(mail, @account)
@processed_mail = MailPresenter.new(mail, @conversation.account)
end
end
+3 -1
View File
@@ -318,7 +318,9 @@ class Message < ApplicationRecord
end
def update_contact_activity
sender.update(last_activity_at: DateTime.now) if sender.is_a?(Contact)
# rubocop:disable Rails/SkipsModelValidations
sender.update_columns(last_activity_at: DateTime.now) if sender.is_a?(Contact)
# rubocop:enable Rails/SkipsModelValidations
end
def update_waiting_since
+3 -21
View File
@@ -174,31 +174,13 @@ class MailPresenter < SimpleDelegator
end
def notification_email_from_chatwoot?
sender_address = original_sender.to_s.downcase
return false if sender_address.blank?
# Notification emails are sent via mailer sender email address.
configured_sender = parse_mail_address(ENV.fetch('MAILER_SENDER_EMAIL', 'Chatwoot <accounts@chatwoot.com>'))&.address&.downcase
return true if configured_sender.present? && sender_address.casecmp?(configured_sender)
reply_thread_email_from_chatwoot?(sender_address)
# notification emails are send via mailer sender email address. so it should match
configured_sender = Mail::Address.new(ENV.fetch('MAILER_SENDER_EMAIL', 'Chatwoot <accounts@chatwoot.com>')).address
original_sender.to_s.casecmp?(configured_sender)
end
private
REPLY_THREAD_SENDER_PATTERN = /^reply\+[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
def reply_thread_email_from_chatwoot?(sender_address)
inbound_domain = @account&.inbound_email_domain.to_s.downcase
return false if inbound_domain.blank?
local_part, domain = sender_address.split('@', 2)
return false if local_part.blank? || domain.blank?
return false unless domain.casecmp?(inbound_domain)
local_part.match?(REPLY_THREAD_SENDER_PATTERN)
end
def parse_mail_address(email)
return if email.blank?
@@ -20,17 +20,12 @@ class Messages::SendEmailNotificationService
def should_send_email_notification?
return false unless message.email_notifiable_message?
return false if bot_sender_message?
return false if message.conversation.contact.email.blank?
return false unless message.account.within_email_rate_limit?
email_reply_enabled?
end
def bot_sender_message?
message.sender_type.in?(%w[AgentBot Captain::Assistant])
end
def email_reply_enabled?
inbox = message.inbox
case inbox.channel.class.to_s
+1 -2
View File
@@ -41,8 +41,7 @@ en:
invalid_email: 'Please enter a valid email address'
authentication_failed: 'Authentication failed. Please check your credentials and try again.'
messages:
reset_password_success: Woot! Request for password reset is successful. Check your mail for instructions.
reset_password_failure: Uh ho! We could not find any user with the specified email.
reset_password: Request for password reset is successful. A email with instructions will be sent to your email if it exists.
reset_password_saml_user: This account uses SAML authentication. Password reset is not available. Please contact your administrator.
login_saml_user: This account uses SAML authentication. Please sign in through your organization's SAML provider.
saml_not_available: SAML authentication is not available in this installation.