mirror of
https://github.com/fosrl/pangolin.git
synced 2026-10-20 19:49:05 +02:00
generateCSV escaped quotes only when a value happened to contain a comma and never neutralised formula prefixes, so every CSV log export could emit attacker controlled cells that execute when an admin opens the file. actor is a virtual API key name validated as nothing more than a non-empty string, and path, host, originalRequestURL, metadata and the AI session bodies come straight from the client request. Each value now goes through a single encoder that collapses CR, LF and CRLF to a literal \n so a record can never be split across rows, prefixes a leading apostrophe when the first character would make a spreadsheet evaluate the cell as a formula, and quotes whenever the field contains a comma or a quote instead of only when it contains a comma. Headers use the same encoder so a column name cannot desynchronise the rows from the header row. The five CSV export endpoints also declare charset=utf-8, matching the content type already used by the log streaming destination. Fixes #3864
48 lines
1.8 KiB
TypeScript
48 lines
1.8 KiB
TypeScript
// Spreadsheet applications (Excel, LibreOffice, Google Sheets) evaluate a cell
|
|
// as a formula when it starts with one of these characters, even if the cell is
|
|
// quoted. Log exports carry attacker controlled text in every column, so a
|
|
// leading apostrophe is prefixed to force the cell to be read as literal text.
|
|
const FORMULA_PREFIX = /^[=+\-@\t\r]/;
|
|
|
|
// CRLF, CR and LF each terminate a record for at least one CSV parser, so a
|
|
// value containing them would otherwise spill across rows and forge new ones.
|
|
// Replacing them with a literal "\n" keeps every record on a single line.
|
|
const LINE_BREAKS = /\r\n|\r|\n/g;
|
|
|
|
/**
|
|
* Render a single value as a CSV field that cannot be reinterpreted by a
|
|
* spreadsheet application or reshape the surrounding file.
|
|
*/
|
|
function toCsvField(value: unknown): string {
|
|
// Array#join renders null and undefined as an empty field, which is what the
|
|
// export has always emitted for those columns, so keep that behaviour.
|
|
if (value === null || value === undefined) {
|
|
return "";
|
|
}
|
|
|
|
const text = String(value).replace(LINE_BREAKS, "\\n");
|
|
|
|
// Quoting does not stop a spreadsheet from evaluating the unquoted value,
|
|
// so the apostrophe has to be added before the field is escaped.
|
|
const cell = FORMULA_PREFIX.test(text) ? `'${text}` : text;
|
|
|
|
if (cell.includes(",") || cell.includes('"')) {
|
|
return `"${cell.replace(/"/g, '""')}"`;
|
|
}
|
|
|
|
return cell;
|
|
}
|
|
|
|
export function generateCSV(data: any[]): string {
|
|
if (data.length === 0) {
|
|
return "orgId,action,actorType,timestamp,actor\n";
|
|
}
|
|
|
|
const headers = Object.keys(data[0]).map(toCsvField).join(",");
|
|
const rows = data.map((row) =>
|
|
Object.values(row).map(toCsvField).join(",")
|
|
);
|
|
|
|
return [headers, ...rows].join("\n");
|
|
}
|