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:
@@ -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) {
|
escapeCsvValue(value) {
|
||||||
if (value === null || value === undefined) {
|
if (value === null || value === undefined) {
|
||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
|
|
||||||
const str = String(value);
|
let str = String(value);
|
||||||
|
|
||||||
// If contains delimiter, newline, or quote, wrap in quotes
|
// Formula/command injection (#22): a leading apostrophe forces the
|
||||||
if (str.includes(this.delimiter) || str.includes('\n') || str.includes('"')) {
|
// 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
|
// Escape existing quotes by doubling them
|
||||||
return '"' + str.replace(/"/g, '""') + '"';
|
return '"' + str.replace(/"/g, '""') + '"';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -97,6 +97,62 @@ const MEASUREMENT = {
|
|||||||
check('tolleranza + presente comunque (0,5)', row[6].replace(',', '.') === '0.5', row.join('|'));
|
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) {
|
if (failures > 0) {
|
||||||
console.error(failures + ' test falliti su csv-export.js');
|
console.error(failures + ' test falliti su csv-export.js');
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
|
|||||||
Reference in New Issue
Block a user