From 787084754bbcb3d8d668793a5162ae16b5e74623 Mon Sep 17 00:00:00 2001 From: jaytbarimbao-collab <300663773+jaytbarimbao-collab@users.noreply.github.com> Date: Mon, 20 Jul 2026 06:55:06 -0400 Subject: [PATCH] fix(ui): guard leading tab and CR in escapeCsvCell against formula injection escapeCsvCell only prefixed the anti-formula "'" when a value started with =, +, -, or @. A spreadsheet strips a leading TAB (\t) or CR (\r) and still evaluates the formula that follows, so a telemetry-sourced value like "\t=HYPERLINK(...)" slipped past the guard unescaped. Extend the guard regex to /^[=+\-@\t\r]/ so a leading tab or CR is guarded the same way. Closes #7439 --- apps/loopover-ui/src/lib/csv-export.test.ts | 11 +++++++++++ apps/loopover-ui/src/lib/csv-export.ts | 5 ++++- 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/apps/loopover-ui/src/lib/csv-export.test.ts b/apps/loopover-ui/src/lib/csv-export.test.ts index 96dbe3b4a9..e2863c0c82 100644 --- a/apps/loopover-ui/src/lib/csv-export.test.ts +++ b/apps/loopover-ui/src/lib/csv-export.test.ts @@ -27,6 +27,17 @@ describe("toCsv (#2198)", () => { ); }); + it("guards leading tab and carriage-return formula-injection vectors (#7439)", () => { + // A spreadsheet strips leading whitespace and still evaluates the formula that follows, so a + // \t / \r before a formula must be guarded the same as a bare =/+/-/@ (which a bare guard missed). + expect(escapeCsvCell("\t=1+1")).toBe("'\t=1+1"); + expect(escapeCsvCell("\t@SUM(A1)")).toBe("'\t@SUM(A1)"); + // A leading \r additionally triggers RFC-4180 quoting, so the guarded value is wrapped. + expect(escapeCsvCell("\r=1+1")).toBe('"\'\r=1+1"'); + // Unchanged: a value with no leading formula/whitespace vector is untouched. + expect(escapeCsvCell("plain")).toBe("plain"); + }); + it("guards formula-injection prefixes from telemetry-sourced values", () => { expect( toCsv( diff --git a/apps/loopover-ui/src/lib/csv-export.ts b/apps/loopover-ui/src/lib/csv-export.ts index 29e5b0af71..6ca24593ea 100644 --- a/apps/loopover-ui/src/lib/csv-export.ts +++ b/apps/loopover-ui/src/lib/csv-export.ts @@ -1,6 +1,9 @@ /** RFC-4180-style CSV cell escaping for client-side ledger exports (#2198). */ export function escapeCsvCell(value: string): string { - const needsFormulaGuard = /^[=+\-@]/.test(value); + // Leading TAB (\t) and CR (\r) are formula-injection vectors too (#7439): a spreadsheet strips the + // leading whitespace and still evaluates the `=`/`+`/`-`/`@` formula that follows, so a bare + // `=+-@` guard misses e.g. "\t=HYPERLINK(...)". Guard them the same way. + const needsFormulaGuard = /^[=+\-@\t\r]/.test(value); const guarded = needsFormulaGuard ? `'${value}` : value; const needsQuotes = /[",\n\r]/.test(guarded); const escaped = guarded.replace(/"/g, '""');