From 0b7811d8821f6814e64a417f88313dbe83bd0f5a Mon Sep 17 00:00:00 2001 From: Ben Senescu <44480372+bensenescu@users.noreply.github.com> Date: Wed, 8 Apr 2026 14:18:27 -0400 Subject: [PATCH] fix: sanitize CSV exports against formula injection (#103) --- src/client/features/backlinks/export.test.ts | 35 ++++++++++++++++++++ src/client/lib/csv.ts | 21 +++++++++++- 2 files changed, 55 insertions(+), 1 deletion(-) diff --git a/src/client/features/backlinks/export.test.ts b/src/client/features/backlinks/export.test.ts index 74e3711..c2f249f 100644 --- a/src/client/features/backlinks/export.test.ts +++ b/src/client/features/backlinks/export.test.ts @@ -90,4 +90,39 @@ describe("buildBacklinksTabCsvFile", () => { ); expect(file.content).toContain('"https://docs.example.com/start"'); }); + it("sanitizes formula-like cell values to prevent CSV injection", () => { + const file = buildBacklinksTabCsvFile({ + tab: "backlinks", + target: "example.com", + rows: { + backlinks: [ + { + domainFrom: "=cmd|' /C calc'!A0", + urlFrom: "+https://evil.example/source", + urlTo: "@https://evil.example/target", + anchor: "\tformula", + itemType: "organic", + isDofollow: true, + relAttributes: [], + rank: 1, + domainFromRank: 1, + pageFromRank: 1, + spamScore: 0, + firstSeen: "2025-01-01", + lastSeen: "2025-01-01", + isLost: false, + isBroken: false, + linksCount: 1, + }, + ], + referringDomains: [], + topPages: [], + }, + }); + + expect(file.content).toContain("\"'=cmd|' /C calc'!A0\""); + expect(file.content).toContain('"\'+https://evil.example/source"'); + expect(file.content).toContain('"\'@https://evil.example/target"'); + expect(file.content).toContain('"\'\tformula"'); + }); }); diff --git a/src/client/lib/csv.ts b/src/client/lib/csv.ts index d800639..011a46b 100644 --- a/src/client/lib/csv.ts +++ b/src/client/lib/csv.ts @@ -3,7 +3,9 @@ import Papa from "papaparse"; type CsvValue = string | number | boolean | null | undefined; export function buildCsv(headers: string[], rows: CsvValue[][]): string { - const normalizedRows = rows.map((row) => row.map((value) => value ?? "")); + const normalizedRows = rows.map((row) => + row.map((value) => sanitizeCsvValue(value ?? "")), + ); return Papa.unparse( { @@ -17,6 +19,23 @@ export function buildCsv(headers: string[], rows: CsvValue[][]): string { ); } +// Prevent CSV injection (formula injection) by prefixing dangerous characters +// with a single quote. See OWASP guidance: +// https://owasp.org/www-community/attacks/CSV_Injection +function sanitizeCsvValue( + value: string | number | boolean, +): string | number | boolean { + if (typeof value !== "string" || value.length === 0) { + return value; + } + + if (["=", "+", "-", "@", "\t", "\r", "\n"].includes(value[0])) { + return `'${value}`; + } + + return value; +} + export function downloadCsv(filename: string, content: string): void { const blob = new Blob([content], { type: "text/csv;charset=utf-8;" }); const url = URL.createObjectURL(blob);