From 61e32474e4d59186b03700599d9bae0226f9b4c5 Mon Sep 17 00:00:00 2001 From: Nick Szaunig Date: Thu, 1 Oct 2026 02:43:44 +0200 Subject: [PATCH] fix(export): neutralize spreadsheet formulas in CSV exports Typed-in values (colony codes, batch numbers, honey types) starting with = + - @ TAB or CR were written as-is and ran as formulas (HYPERLINK, DDE) when the export was opened in Excel or LibreOffice. Such text cells now get a leading apostrophe; numbers keep their sign. Carriage returns are quoted as well. Co-Authored-By: Claude Opus 5.5 (1M context) --- ApiCore-Backend/src/export/csv-field.spec.ts | 21 ++++++++++++++++++++ ApiCore-Backend/src/export/export.service.ts | 14 ++++++++++--- 2 files changed, 32 insertions(+), 3 deletions(-) create mode 100644 ApiCore-Backend/src/export/csv-field.spec.ts diff --git a/ApiCore-Backend/src/export/csv-field.spec.ts b/ApiCore-Backend/src/export/csv-field.spec.ts new file mode 100644 index 0000000..55b80fe --- /dev/null +++ b/ApiCore-Backend/src/export/csv-field.spec.ts @@ -0,0 +1,21 @@ +import { toCsvField } from './export.service'; + +describe('toCsvField', () => { + it('neutralizes cells that a spreadsheet would run as a formula', () => { + expect(toCsvField('=HYPERLINK("http://evil","x")')).toBe( + `"'=HYPERLINK(""http://evil"",""x"")"`, + ); + expect(toCsvField('+1+1')).toBe("'+1+1"); + expect(toCsvField('-2')).toBe("'-2"); + expect(toCsvField('@SUM(A1)')).toBe("'@SUM(A1)"); + }); + + it('keeps numbers, including negative ones, as numbers', () => { + expect(toCsvField(-2.5)).toBe('-2.5'); + expect(toCsvField(0)).toBe('0'); + }); + + it('quotes carriage returns too', () => { + expect(toCsvField('a\rb')).toBe('"a\rb"'); + }); +}); diff --git a/ApiCore-Backend/src/export/export.service.ts b/ApiCore-Backend/src/export/export.service.ts index 0870c4d..a43dfc6 100644 --- a/ApiCore-Backend/src/export/export.service.ts +++ b/ApiCore-Backend/src/export/export.service.ts @@ -71,12 +71,20 @@ function stringifyCsvValue(value: unknown): string { return JSON.stringify(value); } -function toCsvField(value: unknown): string { +// A text cell starting with one of these is run as a formula by Excel and +// LibreOffice (=HYPERLINK(...), DDE). Typed-in values such as a colony code +// are prefixed with ' so they show as text; real numbers are not strings +// and keep their sign. +const FORMULA_TRIGGER = /^[=+\-@\t\r]/; + +export function toCsvField(value: unknown): string { if (value === null || value === undefined) { return ''; } - const text = stringifyCsvValue(value); - if (/[",\n]/.test(text)) { + const raw = stringifyCsvValue(value); + const text = + typeof value === 'string' && FORMULA_TRIGGER.test(raw) ? `'${raw}` : raw; + if (/[",\r\n]/.test(text)) { return `"${text.replace(/"/g, '""')}"`; } return text;