diff --git a/src/frontend/flask_app/static/js/csv-export.js b/src/frontend/flask_app/static/js/csv-export.js index ed01a6c..d618707 100644 --- a/src/frontend/flask_app/static/js/csv-export.js +++ b/src/frontend/flask_app/static/js/csv-export.js @@ -228,17 +228,37 @@ function csvExport() { }, /** - * Escape CSV value (handle quotes, delimiters) + * True when the first non-space character opens a formula/command that + * Excel or LibreOffice would evaluate on open (#22): =, +, -, @, TAB, CR. + * Same check as the backend's neutralise_csv_cell + * (production_export_service.py) - only leading ASCII spaces are + * stripped before looking at the first character. + */ + _startsCsvDangerous(str) { + const stripped = str.replace(/^ +/, ''); + return stripped.length > 0 && /^[=+\-@\t\r]/.test(stripped); + }, + + /** + * Escape CSV value (handle quotes, delimiters, formula injection) */ escapeCsvValue(value) { if (value === null || value === undefined) { return ''; } - const str = String(value); + let str = String(value); - // If contains delimiter, newline, or quote, wrap in quotes - if (str.includes(this.delimiter) || str.includes('\n') || str.includes('"')) { + // Formula/command injection (#22): a leading apostrophe forces the + // cell to stay literal text instead of being evaluated. Only for + // free-text fields - this function is never used on numeric cells + // (those go through formatNumber). + if (this._startsCsvDangerous(str)) { + str = "'" + str; + } + + // If contains delimiter, newline, CR, or quote, wrap in quotes + if (str.includes(this.delimiter) || str.includes('\n') || str.includes('\r') || str.includes('"')) { // Escape existing quotes by doubling them return '"' + str.replace(/"/g, '""') + '"'; } diff --git a/src/frontend/flask_app/tests/js/test_csv_export.js b/src/frontend/flask_app/tests/js/test_csv_export.js index a2b191f..25de58c 100644 --- a/src/frontend/flask_app/tests/js/test_csv_export.js +++ b/src/frontend/flask_app/tests/js/test_csv_export.js @@ -97,6 +97,62 @@ const MEASUREMENT = { check('tolleranza + presente comunque (0,5)', row[6].replace(',', '.') === '0.5', row.join('|')); } +// gitea#22 - formula injection: a lot_number/serial_number/description +// opening a spreadsheet formula must come out neutralised with a leading +// apostrophe, same rule as the backend's neutralise_csv_cell. +{ + const e = csvExport(); + const payload = '=HYPERLINK("http://x","y")'; + const injected = { ...MEASUREMENT, lot_number: payload }; + const csv = e.buildMeasurementCSV([injected]); + const line = csv.split('\n')[1]; + check( + "#22 lot_number '=HYPERLINK(...)' -> neutralizzato con apice", + line.includes("'=HYPERLINK"), + line, + ); + check( + '#22 nessuna cella comincia con = non neutralizzato', + !/;=/.test(line) && !line.startsWith('='), + line, + ); +} + +{ + const e = csvExport(); + // escapeCsvValue directly, for the other dangerous prefixes and the CR case. + check("#22 '+1' -> \"'+1\"", e.escapeCsvValue('+1') === "'+1"); + check("#22 '-cmd' -> \"'-cmd\"", e.escapeCsvValue('-cmd') === "'-cmd"); + check("#22 '@SUM(1)' -> \"'@SUM(1)\"", e.escapeCsvValue('@SUM(1)') === "'@SUM(1)"); + check( + '#22 spazi iniziali non nascondono il prefisso pericoloso', + e.escapeCsvValue(' =evil()') === "' =evil()", + ); + check( + "#22 valore innocuo invariato", + e.escapeCsvValue('Lotto normale') === 'Lotto normale', + ); + check( + '#22 valore con \\r viene quotato', + e.escapeCsvValue('a\rb') === '"a\rb"', + ); +} + +// A negative measured value is a number, not free text: it must stay a +// plain numeric cell (formatNumber), never routed through escapeCsvValue - +// otherwise a "-1,25" would get an apostrophe and stop being a number. +{ + const e = csvExport(); + const negative = { ...MEASUREMENT, value: -1.25 }; + const csv = e.buildMeasurementCSV([negative]); + const row = csv.split('\n')[1].split(';'); + check( + '#22 valore numerico negativo resta numero (-1,25)', + row[3] === '-1,25', + row.join('|'), + ); +} + if (failures > 0) { console.error(failures + ' test falliti su csv-export.js'); process.exit(1);