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
2Patches
3432462f96737feat: harden filter service
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
9fab70aebfd2fix: Use search API instead of filter in the filter in the endpoints (#13651)
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
19683fae74f4Merge branch 'hotfix/4.11.2' into develop
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
1News mentions
0No linked articles in our index yet.