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, '""');