From 3d6205cb336ddc4c321cf491c9c2cb686e263400 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 26 Oct 2026 16:43:38 +1100 Subject: [PATCH] fix(frontend): neutralise spreadsheet formula injection in CSV export (#83) escapeCsv now prefixes values starting with = + - @ tab or CR with a quote (OWASP CSV injection), leaving plain signed numbers alone, and quotes values containing CR. Error and audit report exports carry user-supplied cells, so a payload like =HYPERLINK(...) no longer runs when the export is opened in Excel. XLSX output is unaffected: json_to_sheet stores strings as text cells, not formulas. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_019YPoXzBSj89ZHgw7nfTmbB --- .../src/__tests__/export.test.ts | 26 +++++++++++++++++++ csv-table-hub-main/src/lib/export.ts | 11 ++++++-- 2 files changed, 35 insertions(+), 2 deletions(-) diff --git a/csv-table-hub-main/src/__tests__/export.test.ts b/csv-table-hub-main/src/__tests__/export.test.ts index 6600672..941c9a3 100644 --- a/csv-table-hub-main/src/__tests__/export.test.ts +++ b/csv-table-hub-main/src/__tests__/export.test.ts @@ -175,6 +175,32 @@ describe("downloadErrorReport CSV", () => { // value should be quoted expect(text).toContain('"say ""hello"", world"'); }); + + // #83: user-supplied values must not run as formulas when the CSV is opened. + it.each([ + ['=HYPERLINK("http://x","y")', '\'=HYPERLINK(""http://x"",""y"")'], + ["+cmd|' /C calc'!A0", "'+cmd|' /C calc'!A0"], + ["-2+3", "'-2+3"], + ["@SUM(A1:A2)", "'@SUM(A1:A2)"], + ])("neutralises formula payload %s", async (value, expected) => { + const rows = buildErrorRows("data.csv", [{ rowNumber: 1, column: "c", value, reason: "test" }]); + downloadErrorReport("data.csv", rows, "csv"); + const text = await cap.blob!.text(); + expect(text).toContain(expected); + expect(text).not.toMatch( + new RegExp(`,${value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}(,|$)`, "m"), + ); + }); + + it("leaves plain negative and signed numbers unchanged", async () => { + const rows = buildErrorRows("data.csv", [ + { rowNumber: 1, column: "n", value: "-5", reason: "+3.2" }, + ]); + downloadErrorReport("data.csv", rows, "csv"); + const text = await cap.blob!.text(); + expect(text).toContain(",-5,"); + expect(text).toContain(",+3.2,"); + }); }); // ─── downloadErrorReport — XLSX ──────────────────────────────────────────────── diff --git a/csv-table-hub-main/src/lib/export.ts b/csv-table-hub-main/src/lib/export.ts index b3741e2..4ae4806 100644 --- a/csv-table-hub-main/src/lib/export.ts +++ b/csv-table-hub-main/src/lib/export.ts @@ -40,9 +40,16 @@ export type AuditReportRow = { // ─── CSV ────────────────────────────────────────────────────────────────────── +// Cells starting with these characters are run as formulas by Excel/Sheets +// (OWASP "CSV injection"). Report cells hold user-supplied CSV values, so +// prefix them with a quote. Plain numbers like -5 or +3.2 are left alone. +const FORMULA_START = /^[=+\-@\t\r]/; +const PLAIN_NUMBER = /^[+-]?\d+(\.\d+)?$/; + function escapeCsv(v: unknown): string { - const s = v === undefined || v === null ? "" : String(v); - return /[",\n]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s; + let s = v === undefined || v === null ? "" : String(v); + if (FORMULA_START.test(s) && !PLAIN_NUMBER.test(s)) s = `'${s}`; + return /[",\n\r]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s; } function toCsvBlob(headers: string[], rows: Record[]): Blob {