VYPR
High severity8.5NVD Advisory· Published May 26, 2026· Updated May 26, 2026

CVE-2026-44706

CVE-2026-44706

Description

Chatwoot is a customer engagement suite. From 2.2.0 to before 4.11.2, a SQL injection vulnerability exists in the conversation and contact filter APIs. When filtering by a custom attribute of type date or number using the is_greater_than or is_less_than operators, user-supplied values in the values field of the filter payload are interpolated directly into the SQL query without parameterization. Any authenticated user with access to an account can exploit this to execute arbitrary SQL via time-based blind injection. This affects /api/v1/accounts/{account_id}/conversations/filter, /api/v1/accounts/{account_id}/contacts/filter, and /api/v1/accounts/{account_id}/custom_attribute_definitions. This vulnerability is fixed in 4.11.2.

AI Insight

LLM-synthesized narrative grounded in this CVE's description and references.

Chatwoot before 4.11.2 contains a SQL injection in filter APIs via unparameterized custom attribute values, allowing authenticated users to exfiltrate sensitive data.

Vulnerability

Chatwoot versions 2.2.0 to before 4.11.2 contain a SQL injection vulnerability in the conversation and contact filter APIs. When filtering by a custom attribute of type date or number using the is_greater_than or is_less_than operators, user-supplied values in the values field are interpolated directly into the SQL query without parameterization [1]. Additionally, a second vector exists where the attribute_key of a custom attribute is interpolated unparameterized into a JSON path expression, allowing injection if an attacker creates a custom attribute with a crafted key [1]. The affected endpoints are POST /api/v1/accounts/{account_id}/conversations/filter, POST /api/v1/accounts/{account_id}/contacts/filter, and POST /api/v1/accounts/{account_id}/custom_attribute_definitions [1].

Exploitation

Any authenticated user with access to an account can exploit this vulnerability by sending a crafted filter payload containing malicious SQL in the values field for is_greater_than or is_less_than operators on date or number custom attributes [1]. If the account lacks such a custom attribute, the attacker can create one via the custom_attribute_definitions endpoint to satisfy the precondition [1]. For the second vector, an attacker creates a custom attribute with a crafted attribute_key (e.g., containing quotes or SQL metacharacters) and then triggers the injection on any filter call that references it [1]. Exploitation is performed via time-based blind injection, requiring no special user interaction beyond being authenticated [1].

Impact

A successful attacker can execute arbitrary SQL against the database, enabling cross-account data breaches by reading data across tenant boundaries [1]. This includes exfiltration of user emails, bcrypt password hashes, API access tokens, conversation contents, contact PII, and integration credentials stored in the database [1]. The attacker operates at the privilege level of the application database user, which typically has broad read access.

Mitigation

The vulnerability is fixed in Chatwoot version 4.11.2, released on 2026-05-26 [1]. The patch enforces parameterization via sanitize_sql_array and validates the attribute_key against a strict regex pattern (/\A[\p{L}\p{N}_.\-]+\z/) to prevent injection [1]. Operators should immediately upgrade to 4.11.2 or later. As a workaround, if upgrading is not possible, administrators should review existing custom_attribute_definitions.attribute_key values for SQL metacharacters and monitor filter request payloads for suspicious entries [1].

AI Insight generated on May 26, 2026. Synthesized from this CVE's description and the cited reference URLs; citations are validated against the source bundle.

Affected products

2
  • Chatwoot/Chatwootinferred2 versions
    >=2.2.0,<4.11.2+ 1 more
    • (no CPE)range: >=2.2.0,<4.11.2
    • (no CPE)range: >=2.2.0, <4.11.2

Patches

3
432462f96737

feat: harden filter service

https://github.com/chatwoot/chatwootShivam MishraMar 9, 2026Fixed in 4.11.2via llm-release-walk
10 files changed · +269 23
  • app/helpers/filters/filter_helper.rb+9 5 modified
    @@ -47,11 +47,15 @@ def handle_nil_filter(query_hash, current_index)
     
       def handle_additional_attributes(query_hash, filter_operator_value, data_type)
         if data_type == 'text_case_insensitive'
    -      "LOWER(#{filter_config[:table_name]}.additional_attributes ->> '#{query_hash[:attribute_key]}') " \
    -        "#{filter_operator_value} #{query_hash[:query_operator]}"
    +      ActiveRecord::Base.sanitize_sql_array(
    +        ["LOWER(#{filter_config[:table_name]}.additional_attributes ->> ?) #{filter_operator_value} #{query_hash[:query_operator]}",
    +         query_hash[:attribute_key]]
    +      )
         else
    -      "#{filter_config[:table_name]}.additional_attributes ->> '#{query_hash[:attribute_key]}' " \
    -        "#{filter_operator_value} #{query_hash[:query_operator]} "
    +      ActiveRecord::Base.sanitize_sql_array(
    +        ["#{filter_config[:table_name]}.additional_attributes ->> ? #{filter_operator_value} #{query_hash[:query_operator]} ",
    +         query_hash[:attribute_key]]
    +      )
         end
       end
     
    @@ -70,7 +74,7 @@ def handle_standard_attributes(current_filter, query_hash, current_index, filter
     
       def date_filter(current_filter, query_hash, filter_operator_value)
         "(#{filter_config[:table_name]}.#{query_hash[:attribute_key]})::#{current_filter['data_type']} " \
    -      "#{filter_operator_value}#{current_filter['data_type']} #{query_hash[:query_operator]}"
    +      "#{filter_operator_value} #{query_hash[:query_operator]}"
       end
     
       def text_case_insensitive_filter(query_hash, filter_operator_value)
    
  • app/models/custom_attribute_definition.rb+8 1 modified
    @@ -30,10 +30,12 @@ class CustomAttributeDefinition < ApplicationRecord
     
       scope :with_attribute_model, ->(attribute_model) { attribute_model.presence && where(attribute_model: attribute_model) }
       validates :attribute_display_name, presence: true
    +  before_validation :normalize_attribute_fields
     
       validates :attribute_key,
                 presence: true,
    -            uniqueness: { scope: [:account_id, :attribute_model] }
    +            uniqueness: { scope: [:account_id, :attribute_model] },
    +            format: { with: /\A[\p{L}\p{N}_.\-]+\z/, message: I18n.t('errors.custom_attribute_definition.attribute_key_format') }
     
       validates :attribute_display_type, presence: true
       validates :attribute_model, presence: true
    @@ -48,6 +50,11 @@ class CustomAttributeDefinition < ApplicationRecord
     
       private
     
    +  def normalize_attribute_fields
    +    self.attribute_key = attribute_key.strip if attribute_key.present?
    +    self.attribute_display_name = attribute_display_name.strip if attribute_display_name.present?
    +  end
    +
       def sync_widget_pre_chat_custom_fields
         ::Inboxes::SyncWidgetPreChatCustomFieldsJob.perform_later(account, attribute_key)
       end
    
  • app/services/filter_service.rb+47 13 modified
    @@ -33,9 +33,9 @@ def filter_operation(query_hash, current_index)
         when 'is_not_present'
           @filter_values["value_#{current_index}"] = 'IS NULL'
         when 'is_greater_than', 'is_less_than'
    -      @filter_values["value_#{current_index}"] = lt_gt_filter_values(query_hash)
    +      lt_gt_filter_query(query_hash, current_index)
         when 'days_before'
    -      @filter_values["value_#{current_index}"] = days_before_filter_values(query_hash)
    +      days_before_filter_query(query_hash, current_index)
         else
           @filter_values["value_#{current_index}"] = filter_values(query_hash).to_s
           "= :value_#{current_index}"
    @@ -81,21 +81,29 @@ def string_filter_values(query_hash)
         query_hash['values'].downcase
       end
     
    -  def lt_gt_filter_values(query_hash)
    +  def lt_gt_filter_query(query_hash, current_index)
         attribute_key = query_hash[:attribute_key]
         attribute_model = query_hash['custom_attribute_type'].presence || self.class::ATTRIBUTE_MODEL
         attribute_type = custom_attribute(attribute_key, @account, attribute_model).try(:attribute_display_type)
    -    attribute_data_type = self.class::ATTRIBUTE_TYPES[attribute_type]
    -    value = query_hash['values'][0]
    +    attribute_data_type = self.class::ATTRIBUTE_TYPES[attribute_type] || standard_attribute_data_type(attribute_key)
    +
    +    @filter_values["value_#{current_index}"] = coerce_lt_gt_value(
    +      query_hash['values'][0],
    +      attribute_data_type,
    +      attribute_key
    +    )
         operator = query_hash['filter_operator'] == 'is_less_than' ? '<' : '>'
    -    "#{operator} '#{value}'::#{attribute_data_type}"
    +    "#{operator} :value_#{current_index}"
       end
     
    -  def days_before_filter_values(query_hash)
    +  def days_before_filter_query(query_hash, current_index)
         date = Time.zone.today - query_hash['values'][0].to_i.days
    -    query_hash['values'] = [date.strftime]
    -    query_hash['filter_operator'] = 'is_less_than'
    -    lt_gt_filter_values(query_hash)
    +    updated_query_hash = query_hash.with_indifferent_access.merge(
    +      values: [date.strftime],
    +      filter_operator: 'is_less_than'
    +    )
    +
    +    lt_gt_filter_query(updated_query_hash, current_index)
       end
     
       def set_count_for_all_conversations
    @@ -149,15 +157,39 @@ def attribute_data_type
         @attribute_data_type = self.class::ATTRIBUTE_TYPES[attribute_type]
       end
     
    +  def standard_attribute_data_type(attribute_key)
    +    @filters.each_value do |section|
    +      return section.dig(attribute_key, 'data_type') if section.is_a?(Hash) && section.key?(attribute_key)
    +    end
    +    nil
    +  end
    +
    +  def coerce_lt_gt_value(raw_value, attribute_data_type, attribute_key)
    +    case attribute_data_type
    +    when 'date'
    +      Date.iso8601(raw_value.to_s)
    +    when 'numeric'
    +      BigDecimal(raw_value.to_s)
    +    else
    +      raise CustomExceptions::CustomFilter::InvalidValue.new(attribute_name: attribute_key)
    +    end
    +  rescue Date::Error, ArgumentError, FloatDomainError, TypeError
    +    raise CustomExceptions::CustomFilter::InvalidValue.new(attribute_name: attribute_key)
    +  end
    +
       def build_custom_attr_query(query_hash, current_index)
         filter_operator_value = filter_operation(query_hash, current_index)
         query_operator = query_hash[:query_operator]
         table_name = attribute_model == 'conversation_attribute' ? 'conversations' : 'contacts'
     
         query = if attribute_data_type == 'text'
    -              "LOWER(#{table_name}.custom_attributes ->> '#{@attribute_key}')::#{attribute_data_type} #{filter_operator_value} #{query_operator} "
    +              ActiveRecord::Base.sanitize_sql_array(
    +                ["LOWER(#{table_name}.custom_attributes ->> ?)::#{attribute_data_type} #{filter_operator_value} #{query_operator} ", @attribute_key]
    +              )
                 else
    -              "(#{table_name}.custom_attributes ->> '#{@attribute_key}')::#{attribute_data_type} #{filter_operator_value} #{query_operator} "
    +              ActiveRecord::Base.sanitize_sql_array(
    +                ["(#{table_name}.custom_attributes ->> ?)::#{attribute_data_type} #{filter_operator_value} #{query_operator} ", @attribute_key]
    +              )
                 end
     
         query + not_in_custom_attr_query(table_name, query_hash, attribute_data_type)
    @@ -174,7 +206,9 @@ def custom_attribute(attribute_key, account, custom_attribute_type)
       def not_in_custom_attr_query(table_name, query_hash, attribute_data_type)
         return '' unless query_hash[:filter_operator] == 'not_equal_to'
     
    -    " OR (#{table_name}.custom_attributes ->> '#{@attribute_key}')::#{attribute_data_type} IS NULL "
    +    ActiveRecord::Base.sanitize_sql_array(
    +      [" OR (#{table_name}.custom_attributes ->> ?)::#{attribute_data_type} IS NULL ", @attribute_key]
    +    )
       end
     
       def equals_to_filter_string(filter_operator, current_index)
    
  • config/app.yml+1 1 modified
    @@ -1,5 +1,5 @@
     shared: &shared
    -  version: '4.11.1'
    +  version: '4.11.2'
     
     development:
       <<: *shared
    
  • config/locales/en.yml+1 0 modified
    @@ -117,6 +117,7 @@ en:
           invalid_query_operator: Query operator must be either "AND" or "OR".
           invalid_value: Invalid value. The values provided for %{attribute_name} are invalid
         custom_attribute_definition:
    +      attribute_key_format: must only contain letters, numbers, underscores, hyphens, and dots
           key_conflict: The provided key is not allowed as it might conflict with default attributes.
         mfa:
           already_enabled: MFA is already enabled
    
  • package.json+1 1 modified
    @@ -1,6 +1,6 @@
     {
       "name": "@chatwoot/chatwoot",
    -  "version": "4.11.1",
    +  "version": "4.11.2",
       "license": "MIT",
       "scripts": {
         "eslint": "eslint app/**/*.{js,vue}",
    
  • spec/models/custom_attribute_definition_spec.rb+61 0 added
    @@ -0,0 +1,61 @@
    +# frozen_string_literal: true
    +
    +require 'rails_helper'
    +
    +RSpec.describe CustomAttributeDefinition do
    +  let(:account) { create(:account) }
    +
    +  describe 'validations' do
    +    describe 'attribute_key format' do
    +      it 'allows alphanumeric keys with underscores' do
    +        cad = build(:custom_attribute_definition, account: account, attribute_key: 'order_date_1')
    +        expect(cad).to be_valid
    +      end
    +
    +      it 'allows hyphens and dots' do
    +        cad = build(:custom_attribute_definition, account: account, attribute_key: 'order-date.v2')
    +        expect(cad).to be_valid
    +      end
    +
    +      it 'allows Unicode letters' do
    +        cad = build(:custom_attribute_definition, account: account, attribute_key: '客户类型')
    +        expect(cad).to be_valid
    +      end
    +
    +      it 'rejects keys with single quotes' do
    +        cad = build(:custom_attribute_definition, account: account, attribute_key: "x'||(SELECT 1)||'")
    +        expect(cad).not_to be_valid
    +        expect(cad.errors[:attribute_key]).to be_present
    +      end
    +
    +      it 'rejects keys with spaces' do
    +        cad = build(:custom_attribute_definition, account: account, attribute_key: 'order date')
    +        expect(cad).not_to be_valid
    +      end
    +
    +      it 'rejects keys with semicolons' do
    +        cad = build(:custom_attribute_definition, account: account, attribute_key: 'key; DROP TABLE users--')
    +        expect(cad).not_to be_valid
    +      end
    +
    +      it 'rejects keys with parentheses' do
    +        cad = build(:custom_attribute_definition, account: account, attribute_key: 'key()')
    +        expect(cad).not_to be_valid
    +      end
    +    end
    +  end
    +
    +  describe 'callbacks' do
    +    describe '#strip_attribute_key' do
    +      it 'strips leading and trailing whitespace from attribute_key' do
    +        cad = create(:custom_attribute_definition, account: account, attribute_key: '  order_date  ')
    +        expect(cad.attribute_key).to eq('order_date')
    +      end
    +
    +      it 'strips leading and trailing whitespace from attribute_display_name' do
    +        cad = create(:custom_attribute_definition, account: account, attribute_display_name: '  Order Date  ')
    +        expect(cad.attribute_display_name).to eq('Order Date')
    +      end
    +    end
    +  end
    +end
    
  • spec/services/contacts/filter_service_spec.rb+105 1 modified
    @@ -49,6 +49,11 @@
                account: account,
                attribute_model: 'contact_attribute',
                attribute_display_type: 'date')
    +    create(:custom_attribute_definition,
    +           attribute_key: 'lifetime_value',
    +           account: account,
    +           attribute_model: 'contact_attribute',
    +           attribute_display_type: 'number')
       end
     
       describe '#perform' do
    @@ -60,7 +65,7 @@
     
           en_contact.update!(custom_attributes: { contact_additional_information: 'test custom data' })
           el_contact.update!(custom_attributes: { contact_additional_information: 'test custom data', customer_type: 'platinum' })
    -      cs_contact.update!(custom_attributes: { customer_type: 'platinum', signed_in_at: '2022-01-19' })
    +      cs_contact.update!(custom_attributes: { customer_type: 'platinum', signed_in_at: '2022-01-19', lifetime_value: '120.50' })
         end
     
         context 'with standard attributes - name' do
    @@ -272,6 +277,39 @@
             expect(result[:contacts].pluck(:id)).to include(cs_contact.id)
             expect(result[:contacts].pluck(:id)).not_to include(en_contact.id)
           end
    +
    +      it 'binds last_activity_at comparison values as dates' do
    +        date_value = '2024-01-01'
    +        params[:payload] = [
    +          {
    +            attribute_key: 'last_activity_at',
    +            filter_operator: 'is_greater_than',
    +            values: [date_value],
    +            query_operator: nil
    +          }.with_indifferent_access
    +        ]
    +
    +        service = filter_service.new(account, first_user, params)
    +        filters = service.instance_variable_get(:@filters)['contacts']
    +        condition_query = service.send(:build_condition_query, filters, params[:payload].first, 0)
    +
    +        expect(condition_query).to include('(contacts.last_activity_at)::date > :value_0')
    +        expect(service.instance_variable_get(:@filter_values)['value_0']).to eq(Date.iso8601(date_value))
    +      end
    +
    +      it 'rejects invalid last_activity_at comparison values' do
    +        malicious_value = "2024-01-01'::date OR (SELECT pg_sleep(5)) IS NOT NULL --"
    +        params[:payload] = [
    +          {
    +            attribute_key: 'last_activity_at',
    +            filter_operator: 'is_greater_than',
    +            values: [malicious_value],
    +            query_operator: nil
    +          }.with_indifferent_access
    +        ]
    +
    +        expect { filter_service.new(account, first_user, params).perform }.to raise_error(CustomExceptions::CustomFilter::InvalidValue)
    +      end
         end
     
         context 'with additional attributes' do
    @@ -369,6 +407,72 @@
             expect(result[:contacts].length).to be expected_count
             expect(result[:contacts].pluck(:id)).to include(el_contact.id)
           end
    +
    +      it 'binds custom date comparison values as dates' do
    +        date_value = '2024-01-01'
    +        params[:payload] = [
    +          {
    +            attribute_key: 'signed_in_at',
    +            filter_operator: 'is_less_than',
    +            values: [date_value],
    +            query_operator: nil
    +          }.with_indifferent_access
    +        ]
    +
    +        service = filter_service.new(account, first_user, params)
    +        filters = service.instance_variable_get(:@filters)['contacts']
    +        condition_query = service.send(:build_condition_query, filters, params[:payload].first, 0)
    +
    +        expect(condition_query).to include("(contacts.custom_attributes ->> 'signed_in_at')::date < :value_0")
    +        expect(service.instance_variable_get(:@filter_values)['value_0']).to eq(Date.iso8601(date_value))
    +      end
    +
    +      it 'binds custom numeric comparison values as decimals' do
    +        params[:payload] = [
    +          {
    +            attribute_key: 'lifetime_value',
    +            filter_operator: 'is_greater_than',
    +            values: ['100.25'],
    +            query_operator: nil
    +          }.with_indifferent_access
    +        ]
    +
    +        service = filter_service.new(account, first_user, params)
    +        filters = service.instance_variable_get(:@filters)['contacts']
    +        condition_query = service.send(:build_condition_query, filters, params[:payload].first, 0)
    +
    +        expect(condition_query).to include("(contacts.custom_attributes ->> 'lifetime_value')::numeric > :value_0")
    +        expect(service.instance_variable_get(:@filter_values)['value_0']).to eq(BigDecimal('100.25'))
    +      end
    +
    +      it 'filters by custom numeric attributes' do
    +        params[:payload] = [
    +          {
    +            attribute_key: 'lifetime_value',
    +            filter_operator: 'is_greater_than',
    +            values: ['100.25'],
    +            query_operator: nil
    +          }.with_indifferent_access
    +        ]
    +
    +        result = filter_service.new(account, first_user, params).perform
    +
    +        expect(result[:contacts].pluck(:id)).to eq([cs_contact.id])
    +      end
    +
    +      it 'rejects invalid custom date comparison values' do
    +        malicious_value = "2024-01-01'::date OR (SELECT pg_sleep(5)) IS NOT NULL --"
    +        params[:payload] = [
    +          {
    +            attribute_key: 'signed_in_at',
    +            filter_operator: 'is_less_than',
    +            values: [malicious_value],
    +            query_operator: nil
    +          }.with_indifferent_access
    +        ]
    +
    +        expect { filter_service.new(account, first_user, params).perform }.to raise_error(CustomExceptions::CustomFilter::InvalidValue)
    +      end
         end
       end
     end
    
  • spec/services/conversations/filter_service_spec.rb+35 0 modified
    @@ -417,6 +417,41 @@
             expect(result[:conversations].length).to be expected_count
           end
     
    +      it 'binds created_at comparison values as dates' do
    +        date_value = '2024-01-01'
    +        params[:payload] = [
    +          {
    +            attribute_key: 'created_at',
    +            filter_operator: 'is_greater_than',
    +            values: [date_value],
    +            query_operator: nil,
    +            custom_attribute_type: ''
    +          }.with_indifferent_access
    +        ]
    +
    +        service = filter_service.new(params, user_1, account)
    +        filters = service.instance_variable_get(:@filters)['conversations']
    +        condition_query = service.send(:build_condition_query, filters, params[:payload].first, 0)
    +
    +        expect(condition_query).to include('(conversations.created_at)::date > :value_0')
    +        expect(service.instance_variable_get(:@filter_values)['value_0']).to eq(Date.iso8601(date_value))
    +      end
    +
    +      it 'rejects invalid created_at comparison values' do
    +        malicious_value = "2024-01-01'::date OR (SELECT pg_sleep(5)) IS NOT NULL --"
    +        params[:payload] = [
    +          {
    +            attribute_key: 'created_at',
    +            filter_operator: 'is_greater_than',
    +            values: [malicious_value],
    +            query_operator: nil,
    +            custom_attribute_type: ''
    +          }.with_indifferent_access
    +        ]
    +
    +        expect { filter_service.new(params, user_1, account).perform }.to raise_error(CustomExceptions::CustomFilter::InvalidValue)
    +      end
    +
           it 'filter by created_at and conversation_type' do
             params[:payload] = [
               {
    
  • VERSION_CW+1 1 modified
    @@ -1 +1 @@
    -4.11.1
    +4.11.2
    
9fab70aebfd2

fix: Use search API instead of filter in the filter in the endpoints (#13651)

https://github.com/chatwoot/chatwootPranavFeb 25, 2026Fixed in 4.11.2via llm-release-walk
5 files changed · +27 148
  • app/javascript/dashboard/components-next/NewConversation/components/ComposeNewConversationForm.vue+3 17 modified
    @@ -158,21 +158,7 @@ const isAnyDropdownActive = computed(() => {
     
     const handleContactSearch = value => {
       showContactsDropdown.value = true;
    -  const query = typeof value === 'string' ? value.trim() : '';
    -  const hasAlphabet = Array.from(query).some(char => {
    -    const lower = char.toLowerCase();
    -    const upper = char.toUpperCase();
    -    return lower !== upper;
    -  });
    -  const isEmailLike = /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(query);
    -
    -  const keys = ['email', 'phone_number', 'name'].filter(key => {
    -    if (key === 'phone_number' && hasAlphabet) return false;
    -    if (key === 'name' && isEmailLike) return false;
    -    return true;
    -  });
    -
    -  emit('searchContacts', { keys, query: value });
    +  emit('searchContacts', value);
     };
     
     const handleDropdownUpdate = (type, value) => {
    @@ -187,12 +173,12 @@ const handleDropdownUpdate = (type, value) => {
     
     const searchCcEmails = value => {
       showCcEmailsDropdown.value = true;
    -  emit('searchContacts', { keys: ['email'], query: value });
    +  emit('searchContacts', value);
     };
     
     const searchBccEmails = value => {
       showBccEmailsDropdown.value = true;
    -  emit('searchContacts', { keys: ['email'], query: value });
    +  emit('searchContacts', value);
     };
     
     const setSelectedContact = async ({ value, action, ...rest }) => {
    
  • app/javascript/dashboard/components-next/NewConversation/components/EmailOptions.vue+10 8 modified
    @@ -44,14 +44,16 @@ const bccEmailsArray = computed(() =>
     );
     
     const contactEmailsList = computed(() => {
    -  return props.contacts?.map(({ name, id, email }) => ({
    -    id,
    -    label: email,
    -    email,
    -    thumbnail: { name: name, src: '' },
    -    value: id,
    -    action: 'email',
    -  }));
    +  return props.contacts
    +    ?.filter(contact => contact.email)
    +    .map(({ name, id, email }) => ({
    +      id,
    +      label: email,
    +      email,
    +      thumbnail: { name: name, src: '' },
    +      value: id,
    +      action: 'email',
    +    }));
     });
     
     // Handle updates from TagInput and convert array back to string
    
  • app/javascript/dashboard/components-next/NewConversation/helpers/composeConversationHelper.js+5 23 modified
    @@ -176,32 +176,14 @@ export const prepareWhatsAppMessagePayload = ({
       };
     };
     
    -export const generateContactQuery = ({ keys = ['email'], query }) => {
    -  return {
    -    payload: keys.map(key => {
    -      const filterPayload = {
    -        attribute_key: key,
    -        filter_operator: 'contains',
    -        values: [query],
    -        attribute_model: 'standard',
    -      };
    -      if (keys.findIndex(k => k === key) !== keys.length - 1) {
    -        filterPayload.query_operator = 'or';
    -      }
    -      return filterPayload;
    -    }),
    -  };
    -};
    -
     // API Calls
    -export const searchContacts = async ({ keys, query }) => {
    +export const searchContacts = async query => {
    +  const trimmed = typeof query === 'string' ? query.trim() : '';
    +  if (!trimmed) return [];
    +
       const {
         data: { payload },
    -  } = await ContactAPI.filter(
    -    undefined,
    -    'name',
    -    generateContactQuery({ keys, query })
    -  );
    +  } = await ContactAPI.search(trimmed);
       const camelCasedPayload = camelcaseKeys(payload, { deep: true });
       // Filter contacts that have either phone_number or email
       const filteredPayload = camelCasedPayload?.filter(
    
  • app/javascript/dashboard/components-next/NewConversation/helpers/specs/composeConversationHelper.spec.js+8 96 modified
    @@ -336,70 +336,6 @@ describe('composeConversationHelper', () => {
         });
       });
     
    -  describe('generateContactQuery', () => {
    -    it('generates correct query structure for contact search', () => {
    -      const query = 'test@example.com';
    -      const expected = {
    -        payload: [
    -          {
    -            attribute_key: 'email',
    -            filter_operator: 'contains',
    -            values: [query],
    -            attribute_model: 'standard',
    -          },
    -        ],
    -      };
    -
    -      expect(helpers.generateContactQuery({ keys: ['email'], query })).toEqual(
    -        expected
    -      );
    -    });
    -
    -    it('handles empty query', () => {
    -      const expected = {
    -        payload: [
    -          {
    -            attribute_key: 'email',
    -            filter_operator: 'contains',
    -            values: [''],
    -            attribute_model: 'standard',
    -          },
    -        ],
    -      };
    -
    -      expect(
    -        helpers.generateContactQuery({ keys: ['email'], query: '' })
    -      ).toEqual(expected);
    -    });
    -
    -    it('handles mutliple keys', () => {
    -      const expected = {
    -        payload: [
    -          {
    -            attribute_key: 'email',
    -            filter_operator: 'contains',
    -            values: ['john'],
    -            attribute_model: 'standard',
    -            query_operator: 'or',
    -          },
    -          {
    -            attribute_key: 'phone_number',
    -            filter_operator: 'contains',
    -            values: ['john'],
    -            attribute_model: 'standard',
    -          },
    -        ],
    -      };
    -
    -      expect(
    -        helpers.generateContactQuery({
    -          keys: ['email', 'phone_number'],
    -          query: 'john',
    -        })
    -      ).toEqual(expected);
    -    });
    -  });
    -
       describe('API calls', () => {
         describe('searchContacts', () => {
           it('searches contacts and returns camelCase results', async () => {
    @@ -413,14 +349,11 @@ describe('composeConversationHelper', () => {
               },
             ];
     
    -        ContactAPI.filter.mockResolvedValue({
    +        ContactAPI.search.mockResolvedValue({
               data: { payload: mockPayload },
             });
     
    -        const result = await helpers.searchContacts({
    -          keys: ['email'],
    -          query: 'john',
    -        });
    +        const result = await helpers.searchContacts('john');
     
             expect(result).toEqual([
               {
    @@ -432,16 +365,7 @@ describe('composeConversationHelper', () => {
               },
             ]);
     
    -        expect(ContactAPI.filter).toHaveBeenCalledWith(undefined, 'name', {
    -          payload: [
    -            {
    -              attribute_key: 'email',
    -              filter_operator: 'contains',
    -              values: ['john'],
    -              attribute_model: 'standard',
    -            },
    -          ],
    -        });
    +        expect(ContactAPI.search).toHaveBeenCalledWith('john');
           });
     
           it('searches contacts and returns only contacts with email or phone number', async () => {
    @@ -469,14 +393,11 @@ describe('composeConversationHelper', () => {
               },
             ];
     
    -        ContactAPI.filter.mockResolvedValue({
    +        ContactAPI.search.mockResolvedValue({
               data: { payload: mockPayload },
             });
     
    -        const result = await helpers.searchContacts({
    -          keys: ['email'],
    -          query: 'john',
    -        });
    +        const result = await helpers.searchContacts('john');
     
             // Should only return contacts with either email or phone number
             expect(result).toEqual([
    @@ -496,20 +417,11 @@ describe('composeConversationHelper', () => {
               },
             ]);
     
    -        expect(ContactAPI.filter).toHaveBeenCalledWith(undefined, 'name', {
    -          payload: [
    -            {
    -              attribute_key: 'email',
    -              filter_operator: 'contains',
    -              values: ['john'],
    -              attribute_model: 'standard',
    -            },
    -          ],
    -        });
    +        expect(ContactAPI.search).toHaveBeenCalledWith('john');
           });
     
           it('handles empty search results', async () => {
    -        ContactAPI.filter.mockResolvedValue({
    +        ContactAPI.search.mockResolvedValue({
               data: { payload: [] },
             });
     
    @@ -536,7 +448,7 @@ describe('composeConversationHelper', () => {
               },
             ];
     
    -        ContactAPI.filter.mockResolvedValue({
    +        ContactAPI.search.mockResolvedValue({
               data: { payload: mockPayload },
             });
     
    
  • app/javascript/dashboard/modules/search/components/SearchContactAgentSelector.vue+1 4 modified
    @@ -119,10 +119,7 @@ const debouncedSearch = debounce(async query => {
       }
     
       try {
    -    const contacts = await searchContacts({
    -      keys: ['name', 'email', 'phone_number'],
    -      query,
    -    });
    +    const contacts = await searchContacts(query);
     
         // Add selected contact to top if not already in results
         const allContacts = selectedContact.value
    
19683fae74f4

Merge branch 'hotfix/4.11.2' into develop

https://github.com/chatwoot/chatwootShivam MishraMar 9, 2026Fixed in 4.11.2via release-tag
10 files changed · +269 23
  • app/helpers/filters/filter_helper.rb+9 5 modified
    @@ -47,11 +47,15 @@ def handle_nil_filter(query_hash, current_index)
     
       def handle_additional_attributes(query_hash, filter_operator_value, data_type)
         if data_type == 'text_case_insensitive'
    -      "LOWER(#{filter_config[:table_name]}.additional_attributes ->> '#{query_hash[:attribute_key]}') " \
    -        "#{filter_operator_value} #{query_hash[:query_operator]}"
    +      ActiveRecord::Base.sanitize_sql_array(
    +        ["LOWER(#{filter_config[:table_name]}.additional_attributes ->> ?) #{filter_operator_value} #{query_hash[:query_operator]}",
    +         query_hash[:attribute_key]]
    +      )
         else
    -      "#{filter_config[:table_name]}.additional_attributes ->> '#{query_hash[:attribute_key]}' " \
    -        "#{filter_operator_value} #{query_hash[:query_operator]} "
    +      ActiveRecord::Base.sanitize_sql_array(
    +        ["#{filter_config[:table_name]}.additional_attributes ->> ? #{filter_operator_value} #{query_hash[:query_operator]} ",
    +         query_hash[:attribute_key]]
    +      )
         end
       end
     
    @@ -70,7 +74,7 @@ def handle_standard_attributes(current_filter, query_hash, current_index, filter
     
       def date_filter(current_filter, query_hash, filter_operator_value)
         "(#{filter_config[:table_name]}.#{query_hash[:attribute_key]})::#{current_filter['data_type']} " \
    -      "#{filter_operator_value}#{current_filter['data_type']} #{query_hash[:query_operator]}"
    +      "#{filter_operator_value} #{query_hash[:query_operator]}"
       end
     
       def text_case_insensitive_filter(query_hash, filter_operator_value)
    
  • app/models/custom_attribute_definition.rb+8 1 modified
    @@ -30,10 +30,12 @@ class CustomAttributeDefinition < ApplicationRecord
     
       scope :with_attribute_model, ->(attribute_model) { attribute_model.presence && where(attribute_model: attribute_model) }
       validates :attribute_display_name, presence: true
    +  before_validation :normalize_attribute_fields
     
       validates :attribute_key,
                 presence: true,
    -            uniqueness: { scope: [:account_id, :attribute_model] }
    +            uniqueness: { scope: [:account_id, :attribute_model] },
    +            format: { with: /\A[\p{L}\p{N}_.\-]+\z/, message: I18n.t('errors.custom_attribute_definition.attribute_key_format') }
     
       validates :attribute_display_type, presence: true
       validates :attribute_model, presence: true
    @@ -48,6 +50,11 @@ class CustomAttributeDefinition < ApplicationRecord
     
       private
     
    +  def normalize_attribute_fields
    +    self.attribute_key = attribute_key.strip if attribute_key.present?
    +    self.attribute_display_name = attribute_display_name.strip if attribute_display_name.present?
    +  end
    +
       def sync_widget_pre_chat_custom_fields
         ::Inboxes::SyncWidgetPreChatCustomFieldsJob.perform_later(account, attribute_key)
       end
    
  • app/services/filter_service.rb+47 13 modified
    @@ -33,9 +33,9 @@ def filter_operation(query_hash, current_index)
         when 'is_not_present'
           @filter_values["value_#{current_index}"] = 'IS NULL'
         when 'is_greater_than', 'is_less_than'
    -      @filter_values["value_#{current_index}"] = lt_gt_filter_values(query_hash)
    +      lt_gt_filter_query(query_hash, current_index)
         when 'days_before'
    -      @filter_values["value_#{current_index}"] = days_before_filter_values(query_hash)
    +      days_before_filter_query(query_hash, current_index)
         else
           @filter_values["value_#{current_index}"] = filter_values(query_hash).to_s
           "= :value_#{current_index}"
    @@ -81,21 +81,29 @@ def string_filter_values(query_hash)
         query_hash['values'].downcase
       end
     
    -  def lt_gt_filter_values(query_hash)
    +  def lt_gt_filter_query(query_hash, current_index)
         attribute_key = query_hash[:attribute_key]
         attribute_model = query_hash['custom_attribute_type'].presence || self.class::ATTRIBUTE_MODEL
         attribute_type = custom_attribute(attribute_key, @account, attribute_model).try(:attribute_display_type)
    -    attribute_data_type = self.class::ATTRIBUTE_TYPES[attribute_type]
    -    value = query_hash['values'][0]
    +    attribute_data_type = self.class::ATTRIBUTE_TYPES[attribute_type] || standard_attribute_data_type(attribute_key)
    +
    +    @filter_values["value_#{current_index}"] = coerce_lt_gt_value(
    +      query_hash['values'][0],
    +      attribute_data_type,
    +      attribute_key
    +    )
         operator = query_hash['filter_operator'] == 'is_less_than' ? '<' : '>'
    -    "#{operator} '#{value}'::#{attribute_data_type}"
    +    "#{operator} :value_#{current_index}"
       end
     
    -  def days_before_filter_values(query_hash)
    +  def days_before_filter_query(query_hash, current_index)
         date = Time.zone.today - query_hash['values'][0].to_i.days
    -    query_hash['values'] = [date.strftime]
    -    query_hash['filter_operator'] = 'is_less_than'
    -    lt_gt_filter_values(query_hash)
    +    updated_query_hash = query_hash.with_indifferent_access.merge(
    +      values: [date.strftime],
    +      filter_operator: 'is_less_than'
    +    )
    +
    +    lt_gt_filter_query(updated_query_hash, current_index)
       end
     
       def set_count_for_all_conversations
    @@ -149,15 +157,39 @@ def attribute_data_type
         @attribute_data_type = self.class::ATTRIBUTE_TYPES[attribute_type]
       end
     
    +  def standard_attribute_data_type(attribute_key)
    +    @filters.each_value do |section|
    +      return section.dig(attribute_key, 'data_type') if section.is_a?(Hash) && section.key?(attribute_key)
    +    end
    +    nil
    +  end
    +
    +  def coerce_lt_gt_value(raw_value, attribute_data_type, attribute_key)
    +    case attribute_data_type
    +    when 'date'
    +      Date.iso8601(raw_value.to_s)
    +    when 'numeric'
    +      BigDecimal(raw_value.to_s)
    +    else
    +      raise CustomExceptions::CustomFilter::InvalidValue.new(attribute_name: attribute_key)
    +    end
    +  rescue Date::Error, ArgumentError, FloatDomainError, TypeError
    +    raise CustomExceptions::CustomFilter::InvalidValue.new(attribute_name: attribute_key)
    +  end
    +
       def build_custom_attr_query(query_hash, current_index)
         filter_operator_value = filter_operation(query_hash, current_index)
         query_operator = query_hash[:query_operator]
         table_name = attribute_model == 'conversation_attribute' ? 'conversations' : 'contacts'
     
         query = if attribute_data_type == 'text'
    -              "LOWER(#{table_name}.custom_attributes ->> '#{@attribute_key}')::#{attribute_data_type} #{filter_operator_value} #{query_operator} "
    +              ActiveRecord::Base.sanitize_sql_array(
    +                ["LOWER(#{table_name}.custom_attributes ->> ?)::#{attribute_data_type} #{filter_operator_value} #{query_operator} ", @attribute_key]
    +              )
                 else
    -              "(#{table_name}.custom_attributes ->> '#{@attribute_key}')::#{attribute_data_type} #{filter_operator_value} #{query_operator} "
    +              ActiveRecord::Base.sanitize_sql_array(
    +                ["(#{table_name}.custom_attributes ->> ?)::#{attribute_data_type} #{filter_operator_value} #{query_operator} ", @attribute_key]
    +              )
                 end
     
         query + not_in_custom_attr_query(table_name, query_hash, attribute_data_type)
    @@ -174,7 +206,9 @@ def custom_attribute(attribute_key, account, custom_attribute_type)
       def not_in_custom_attr_query(table_name, query_hash, attribute_data_type)
         return '' unless query_hash[:filter_operator] == 'not_equal_to'
     
    -    " OR (#{table_name}.custom_attributes ->> '#{@attribute_key}')::#{attribute_data_type} IS NULL "
    +    ActiveRecord::Base.sanitize_sql_array(
    +      [" OR (#{table_name}.custom_attributes ->> ?)::#{attribute_data_type} IS NULL ", @attribute_key]
    +    )
       end
     
       def equals_to_filter_string(filter_operator, current_index)
    
  • config/app.yml+1 1 modified
    @@ -1,5 +1,5 @@
     shared: &shared
    -  version: '4.11.1'
    +  version: '4.11.2'
     
     development:
       <<: *shared
    
  • config/locales/en.yml+1 0 modified
    @@ -122,6 +122,7 @@ en:
           invalid_query_operator: Query operator must be either "AND" or "OR".
           invalid_value: Invalid value. The values provided for %{attribute_name} are invalid
         custom_attribute_definition:
    +      attribute_key_format: must only contain letters, numbers, underscores, hyphens, and dots
           key_conflict: The provided key is not allowed as it might conflict with default attributes.
         mfa:
           already_enabled: MFA is already enabled
    
  • package.json+1 1 modified
    @@ -1,6 +1,6 @@
     {
       "name": "@chatwoot/chatwoot",
    -  "version": "4.11.1",
    +  "version": "4.11.2",
       "license": "MIT",
       "scripts": {
         "eslint": "eslint app/**/*.{js,vue}",
    
  • spec/models/custom_attribute_definition_spec.rb+61 0 added
    @@ -0,0 +1,61 @@
    +# frozen_string_literal: true
    +
    +require 'rails_helper'
    +
    +RSpec.describe CustomAttributeDefinition do
    +  let(:account) { create(:account) }
    +
    +  describe 'validations' do
    +    describe 'attribute_key format' do
    +      it 'allows alphanumeric keys with underscores' do
    +        cad = build(:custom_attribute_definition, account: account, attribute_key: 'order_date_1')
    +        expect(cad).to be_valid
    +      end
    +
    +      it 'allows hyphens and dots' do
    +        cad = build(:custom_attribute_definition, account: account, attribute_key: 'order-date.v2')
    +        expect(cad).to be_valid
    +      end
    +
    +      it 'allows Unicode letters' do
    +        cad = build(:custom_attribute_definition, account: account, attribute_key: '客户类型')
    +        expect(cad).to be_valid
    +      end
    +
    +      it 'rejects keys with single quotes' do
    +        cad = build(:custom_attribute_definition, account: account, attribute_key: "x'||(SELECT 1)||'")
    +        expect(cad).not_to be_valid
    +        expect(cad.errors[:attribute_key]).to be_present
    +      end
    +
    +      it 'rejects keys with spaces' do
    +        cad = build(:custom_attribute_definition, account: account, attribute_key: 'order date')
    +        expect(cad).not_to be_valid
    +      end
    +
    +      it 'rejects keys with semicolons' do
    +        cad = build(:custom_attribute_definition, account: account, attribute_key: 'key; DROP TABLE users--')
    +        expect(cad).not_to be_valid
    +      end
    +
    +      it 'rejects keys with parentheses' do
    +        cad = build(:custom_attribute_definition, account: account, attribute_key: 'key()')
    +        expect(cad).not_to be_valid
    +      end
    +    end
    +  end
    +
    +  describe 'callbacks' do
    +    describe '#strip_attribute_key' do
    +      it 'strips leading and trailing whitespace from attribute_key' do
    +        cad = create(:custom_attribute_definition, account: account, attribute_key: '  order_date  ')
    +        expect(cad.attribute_key).to eq('order_date')
    +      end
    +
    +      it 'strips leading and trailing whitespace from attribute_display_name' do
    +        cad = create(:custom_attribute_definition, account: account, attribute_display_name: '  Order Date  ')
    +        expect(cad.attribute_display_name).to eq('Order Date')
    +      end
    +    end
    +  end
    +end
    
  • spec/services/contacts/filter_service_spec.rb+105 1 modified
    @@ -49,6 +49,11 @@
                account: account,
                attribute_model: 'contact_attribute',
                attribute_display_type: 'date')
    +    create(:custom_attribute_definition,
    +           attribute_key: 'lifetime_value',
    +           account: account,
    +           attribute_model: 'contact_attribute',
    +           attribute_display_type: 'number')
       end
     
       describe '#perform' do
    @@ -60,7 +65,7 @@
     
           en_contact.update!(custom_attributes: { contact_additional_information: 'test custom data' })
           el_contact.update!(custom_attributes: { contact_additional_information: 'test custom data', customer_type: 'platinum' })
    -      cs_contact.update!(custom_attributes: { customer_type: 'platinum', signed_in_at: '2022-01-19' })
    +      cs_contact.update!(custom_attributes: { customer_type: 'platinum', signed_in_at: '2022-01-19', lifetime_value: '120.50' })
         end
     
         context 'with standard attributes - name' do
    @@ -272,6 +277,39 @@
             expect(result[:contacts].pluck(:id)).to include(cs_contact.id)
             expect(result[:contacts].pluck(:id)).not_to include(en_contact.id)
           end
    +
    +      it 'binds last_activity_at comparison values as dates' do
    +        date_value = '2024-01-01'
    +        params[:payload] = [
    +          {
    +            attribute_key: 'last_activity_at',
    +            filter_operator: 'is_greater_than',
    +            values: [date_value],
    +            query_operator: nil
    +          }.with_indifferent_access
    +        ]
    +
    +        service = filter_service.new(account, first_user, params)
    +        filters = service.instance_variable_get(:@filters)['contacts']
    +        condition_query = service.send(:build_condition_query, filters, params[:payload].first, 0)
    +
    +        expect(condition_query).to include('(contacts.last_activity_at)::date > :value_0')
    +        expect(service.instance_variable_get(:@filter_values)['value_0']).to eq(Date.iso8601(date_value))
    +      end
    +
    +      it 'rejects invalid last_activity_at comparison values' do
    +        malicious_value = "2024-01-01'::date OR (SELECT pg_sleep(5)) IS NOT NULL --"
    +        params[:payload] = [
    +          {
    +            attribute_key: 'last_activity_at',
    +            filter_operator: 'is_greater_than',
    +            values: [malicious_value],
    +            query_operator: nil
    +          }.with_indifferent_access
    +        ]
    +
    +        expect { filter_service.new(account, first_user, params).perform }.to raise_error(CustomExceptions::CustomFilter::InvalidValue)
    +      end
         end
     
         context 'with additional attributes' do
    @@ -369,6 +407,72 @@
             expect(result[:contacts].length).to be expected_count
             expect(result[:contacts].pluck(:id)).to include(el_contact.id)
           end
    +
    +      it 'binds custom date comparison values as dates' do
    +        date_value = '2024-01-01'
    +        params[:payload] = [
    +          {
    +            attribute_key: 'signed_in_at',
    +            filter_operator: 'is_less_than',
    +            values: [date_value],
    +            query_operator: nil
    +          }.with_indifferent_access
    +        ]
    +
    +        service = filter_service.new(account, first_user, params)
    +        filters = service.instance_variable_get(:@filters)['contacts']
    +        condition_query = service.send(:build_condition_query, filters, params[:payload].first, 0)
    +
    +        expect(condition_query).to include("(contacts.custom_attributes ->> 'signed_in_at')::date < :value_0")
    +        expect(service.instance_variable_get(:@filter_values)['value_0']).to eq(Date.iso8601(date_value))
    +      end
    +
    +      it 'binds custom numeric comparison values as decimals' do
    +        params[:payload] = [
    +          {
    +            attribute_key: 'lifetime_value',
    +            filter_operator: 'is_greater_than',
    +            values: ['100.25'],
    +            query_operator: nil
    +          }.with_indifferent_access
    +        ]
    +
    +        service = filter_service.new(account, first_user, params)
    +        filters = service.instance_variable_get(:@filters)['contacts']
    +        condition_query = service.send(:build_condition_query, filters, params[:payload].first, 0)
    +
    +        expect(condition_query).to include("(contacts.custom_attributes ->> 'lifetime_value')::numeric > :value_0")
    +        expect(service.instance_variable_get(:@filter_values)['value_0']).to eq(BigDecimal('100.25'))
    +      end
    +
    +      it 'filters by custom numeric attributes' do
    +        params[:payload] = [
    +          {
    +            attribute_key: 'lifetime_value',
    +            filter_operator: 'is_greater_than',
    +            values: ['100.25'],
    +            query_operator: nil
    +          }.with_indifferent_access
    +        ]
    +
    +        result = filter_service.new(account, first_user, params).perform
    +
    +        expect(result[:contacts].pluck(:id)).to eq([cs_contact.id])
    +      end
    +
    +      it 'rejects invalid custom date comparison values' do
    +        malicious_value = "2024-01-01'::date OR (SELECT pg_sleep(5)) IS NOT NULL --"
    +        params[:payload] = [
    +          {
    +            attribute_key: 'signed_in_at',
    +            filter_operator: 'is_less_than',
    +            values: [malicious_value],
    +            query_operator: nil
    +          }.with_indifferent_access
    +        ]
    +
    +        expect { filter_service.new(account, first_user, params).perform }.to raise_error(CustomExceptions::CustomFilter::InvalidValue)
    +      end
         end
       end
     end
    
  • spec/services/conversations/filter_service_spec.rb+35 0 modified
    @@ -417,6 +417,41 @@
             expect(result[:conversations].length).to be expected_count
           end
     
    +      it 'binds created_at comparison values as dates' do
    +        date_value = '2024-01-01'
    +        params[:payload] = [
    +          {
    +            attribute_key: 'created_at',
    +            filter_operator: 'is_greater_than',
    +            values: [date_value],
    +            query_operator: nil,
    +            custom_attribute_type: ''
    +          }.with_indifferent_access
    +        ]
    +
    +        service = filter_service.new(params, user_1, account)
    +        filters = service.instance_variable_get(:@filters)['conversations']
    +        condition_query = service.send(:build_condition_query, filters, params[:payload].first, 0)
    +
    +        expect(condition_query).to include('(conversations.created_at)::date > :value_0')
    +        expect(service.instance_variable_get(:@filter_values)['value_0']).to eq(Date.iso8601(date_value))
    +      end
    +
    +      it 'rejects invalid created_at comparison values' do
    +        malicious_value = "2024-01-01'::date OR (SELECT pg_sleep(5)) IS NOT NULL --"
    +        params[:payload] = [
    +          {
    +            attribute_key: 'created_at',
    +            filter_operator: 'is_greater_than',
    +            values: [malicious_value],
    +            query_operator: nil,
    +            custom_attribute_type: ''
    +          }.with_indifferent_access
    +        ]
    +
    +        expect { filter_service.new(params, user_1, account).perform }.to raise_error(CustomExceptions::CustomFilter::InvalidValue)
    +      end
    +
           it 'filter by created_at and conversation_type' do
             params[:payload] = [
               {
    
  • VERSION_CW+1 1 modified
    @@ -1 +1 @@
    -4.11.1
    +4.11.2
    

Vulnerability mechanics

Root cause

"User-supplied filter values for date/number custom attributes are interpolated directly into SQL queries without parameterization, enabling SQL injection."

Attack vector

An authenticated attacker with access to any Chatwoot account can send a crafted POST request to `/api/v1/accounts/{account_id}/conversations/filter`, `/api/v1/accounts/{account_id}/contacts/filter`, or `/api/v1/accounts/{account_id}/custom_attribute_definitions`. The payload must use a custom attribute of type `date` or `number` with the `is_greater_than` or `is_less_than` operator, and include a malicious SQL fragment in the `values` field. Because the old code interpolated the value directly into the SQL query as `'#{value}'::date`, an attacker can break out of the string literal with a single quote and inject arbitrary SQL, such as time-based blind payloads using `pg_sleep()`. The patch tests demonstrate this with the value `"2024-01-01'::date OR (SELECT pg_sleep(5)) IS NOT NULL --"` [patch_id=2566844].

Affected code

The vulnerability resides in `app/services/filter_service.rb`, specifically in the `lt_gt_filter_values` method (renamed to `lt_gt_filter_query` in the patch). This method previously interpolated user-supplied values directly into SQL strings using `"#{operator} '#{value}'::#{attribute_data_type}"` without parameterization. The `build_custom_attr_query` and `not_in_custom_attr_query` methods also interpolated the `@attribute_key` directly into SQL. The `handle_additional_attributes` method in `app/helpers/filters/filter_helper.rb` had the same pattern for additional attributes. The patch also adds input validation in `app/models/custom_attribute_definition.rb` to restrict `attribute_key` format.

What the fix does

The patch replaces direct string interpolation with parameterized queries using `ActiveRecord::Base.sanitize_sql_array` and bind parameters (`:value_#{current_index}`). The `lt_gt_filter_values` method is renamed to `lt_gt_filter_query` and now calls `coerce_lt_gt_value`, which parses the raw value into a `Date` or `BigDecimal` object (raising `CustomExceptions::CustomFilter::InvalidValue` on failure) before binding it. The `build_custom_attr_query` and `not_in_custom_attr_query` methods now use `sanitize_sql_array` with `?` placeholders for the `attribute_key`. Additionally, `CustomAttributeDefinition` now validates `attribute_key` format with a regex (`\A[\p{L}\p{N}_.\-]+\z`) and strips whitespace, preventing injection through the attribute key itself. The `date_filter` helper also removes a stray `data_type` concatenation that was duplicating the type cast.

Preconditions

  • authAttacker must be authenticated with access to a Chatwoot account
  • configThe account must have at least one custom attribute definition of type 'date' or 'number'
  • networkAttacker sends HTTP POST to one of the three filter API endpoints
  • inputPayload must include a custom attribute key with filter_operator is_greater_than or is_less_than and a malicious values array

Generated on May 26, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.

References

1

News mentions

0

No linked articles in our index yet.