fix(csv): escapeCsvValue neutralizza formula injection, quota anche \r (#22)

Stessa regola del backend (neutralise_csv_cell): un valore che, tolti gli
spazi iniziali, comincia con =, +, -, @, TAB o CR riceve un apice davanti
prima di finire in cella - Excel/LibreOffice lo trattano come testo invece
che come formula. Solo per campi testuali (lot/seriale/descrizione):
formatNumber, usato per le colonne numeriche, non passa da qui, quindi un
valore negativo resta un numero. Aggiunto anche il quoting per \r, che
prima passava non quotato.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-21 22:53:36 +02:00
parent e8d21b78a9
commit c0583da478
2 changed files with 80 additions and 4 deletions
@@ -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);