From a929955cc367042f39bd5b99c84efa2022f1e317 Mon Sep 17 00:00:00 2001 From: Vishnu Narayanan Date: Thu, 13 Aug 2026 18:09:38 +0530 Subject: [PATCH] fix: escape formula characters in contact CSV export (#15334) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Description Contact export builds its CSV with the standard library `CSV`, which writes user-supplied values (name, email, phone, custom attributes) verbatim. When a cell begins with a formula character (`=`, `+`, `-`, `@`, and tab/CR), spreadsheet applications interpret it as a formula on open. This switches the export to `CSVSafe` (the `csv-safe` gem already used by the v2 report exports), which prefixes such fields so they are treated as text. No new dependency, no behavioural change beyond neutralising formula-leading cells. Note: phone numbers stored with a leading `+` are now prefixed with a single quote in the exported file, consistent with how the report exports already behave. Ref https://linear.app/chatwoot/issue/CW-7473 ## Type of change - [x] Bug fix (non-breaking change which fixes an issue) ## How Has This Been Tested? `bundle exec rspec spec/jobs/account/contacts_export_job_spec.rb` — added a case asserting a formula-leading contact value is neutralised on export; existing cases updated for the phone-number prefix. ## Checklist: - [x] My code follows the style guidelines of this project - [x] I have performed a self-review of my code - [x] My changes generate no new warnings - [x] I have added tests that prove my fix is effective or that my feature works - [x] New and existing unit tests pass locally with my changes --- app/jobs/account/contacts_export_job.rb | 2 +- spec/jobs/account/contacts_export_job_spec.rb | 15 ++++++++++++++- 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/app/jobs/account/contacts_export_job.rb b/app/jobs/account/contacts_export_job.rb index 52b3412e0..2b76f5703 100644 --- a/app/jobs/account/contacts_export_job.rb +++ b/app/jobs/account/contacts_export_job.rb @@ -20,7 +20,7 @@ class Account::ContactsExportJob < ApplicationJob contacts_to_export = contacts.to_a preload_contact_labels(contacts_to_export) if headers.include?(LABELS_COLUMN) - csv_data = CSV.generate do |csv| + csv_data = CSVSafe.generate do |csv| csv << headers contacts_to_export.each do |contact| csv << headers.map { |header| value_for_header(contact, header) } diff --git a/spec/jobs/account/contacts_export_job_spec.rb b/spec/jobs/account/contacts_export_job_spec.rb index 0714fb55b..63c5823ee 100644 --- a/spec/jobs/account/contacts_export_job_spec.rb +++ b/spec/jobs/account/contacts_export_job_spec.rb @@ -81,7 +81,20 @@ RSpec.describe Account::ContactsExportJob do expect(csv_data.length).to eq(account.contacts.count) expect(emails).to include('test1@text.example', 'test2@text.example') - expect(phone_numbers).to include('+910808080818', '+910808080808') + # Phone numbers lead with "+" (a formula character), so they are prefixed with "'" on export. + expect(phone_numbers).to include("'+910808080818", "'+910808080808") + end + + it 'neutralises formula-leading characters in exported values' do + create(:contact, account: account, name: '=HYPERLINK("http://evil.example")', email: 'formula@text.example') + + described_class.perform_now(account.id, user.id, %w[id name email], {}) + + csv_content = account.contacts_export.download.force_encoding('UTF-8').delete_prefix("\xEF\xBB\xBF") + csv_data = CSV.parse(csv_content, headers: true) + row = csv_data.find { |r| r['email'] == 'formula@text.example' } + + expect(row['name']).to start_with("'") end it 'exports labels when requested through column names' do