Files
pn-new-crm/src/lib/services/expense-export.tsx
Matt a335dbc117 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>
2026-06-02 12:18:07 +02:00

160 lines
5.3 KiB
TypeScript

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';
import { renderPdf } from '@/lib/pdf/render';
import { resolvePortLogo } from '@/lib/pdf/brand-kit/logo';
import { ParentCompanyExpensePdf } from '@/lib/pdf/templates/parent-company-expense';
import { getRate } from '@/lib/services/currency';
import { logger } from '@/lib/logger';
import type { ListExpensesInput } from '@/lib/validators/expenses';
async function fetchAllExpenses(portId: string, query: ListExpensesInput) {
const conditions: ReturnType<typeof eq>[] = [
eq(expenses.portId, portId) as ReturnType<typeof eq>,
];
if (!query.includeArchived) {
conditions.push(isNull(expenses.archivedAt) as unknown as ReturnType<typeof eq>);
}
if (query.category) {
conditions.push(eq(expenses.category, query.category) as ReturnType<typeof eq>);
}
if (query.paymentStatus) {
conditions.push(eq(expenses.paymentStatus, query.paymentStatus) as ReturnType<typeof eq>);
}
if (query.currency) {
conditions.push(eq(expenses.currency, query.currency) as ReturnType<typeof eq>);
}
if (query.payer) {
conditions.push(eq(expenses.payer, query.payer) as ReturnType<typeof eq>);
}
if (query.dateFrom) {
conditions.push(
gte(expenses.expenseDate, new Date(query.dateFrom)) as unknown as ReturnType<typeof eq>,
);
}
if (query.dateTo) {
conditions.push(
lte(expenses.expenseDate, new Date(query.dateTo)) as unknown as ReturnType<typeof eq>,
);
}
if (query.search) {
conditions.push(
or(
ilike(expenses.establishmentName, `%${query.search}%`),
ilike(expenses.description, `%${query.search}%`),
) as unknown as ReturnType<typeof eq>,
);
}
return db
.select()
.from(expenses)
.where(and(...conditions));
}
export async function exportCsv(portId: string, query: ListExpensesInput): Promise<string> {
const rows = await fetchAllExpenses(portId, query);
// papaparse handles all the CSV edge cases (commas in fields, embedded
// 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: sanitizeCsvCell(r.establishmentName ?? ''),
Category: sanitizeCsvCell(r.category ?? ''),
Amount: r.amount,
Currency: r.currency,
'Amount USD': r.amountUsd ?? 'N/A',
'Payment Status': sanitizeCsvCell(r.paymentStatus ?? ''),
'Payment Method': sanitizeCsvCell(r.paymentMethod ?? ''),
Description: sanitizeCsvCell(r.description ?? ''),
})),
{
columns: [
'Date',
'Establishment',
'Category',
'Amount',
'Currency',
'Amount USD',
'Payment Status',
'Payment Method',
'Description',
],
},
);
}
/**
* Legacy text-only PDF export superseded by the streaming
* `streamExpensePdf` in `src/lib/services/expense-pdf.service.ts`.
* The new service supports receipt-image embedding, sharp resize for
* stupidly-large attachments, and streaming output so hundreds of
* expenses no longer OOM the process.
*
* See `src/app/api/v1/expenses/export/pdf/route.ts` for the live route.
*/
export async function exportParentCompany(
portId: string,
query: ListExpensesInput,
): Promise<Buffer> {
// BR-043: Convert all amounts to EUR, add 5% management fee
const rows = await fetchAllExpenses(portId, query);
const eurRate = await getRate('USD', 'EUR');
if (!eurRate) {
logger.warn('EUR rate unavailable for parent company export, using 1:1 fallback');
}
const rate = eurRate ?? 1;
const convertedRows = rows.map((r) => {
const amountUsd = r.amountUsd ? Number(r.amountUsd) : Number(r.amount);
const amountEur = Number((amountUsd * rate).toFixed(2));
return {
date: r.expenseDate ? (new Date(r.expenseDate).toISOString().split('T')[0] ?? '') : '',
establishment: r.establishmentName ?? '-',
category: r.category ?? '-',
amountEur,
};
});
const subtotal = convertedRows.reduce((sum, r) => sum + r.amountEur, 0);
const fee = Number((subtotal * 0.05).toFixed(2));
const total = Number((subtotal + fee).toFixed(2));
const [port, logo] = await Promise.all([
db.query.ports.findFirst({ where: eq(ports.id, portId) }),
resolvePortLogo(portId),
]);
if (!port) {
throw new Error(`Cannot render expense export: port ${portId} not found.`);
}
return renderPdf(
<ParentCompanyExpensePdf
portName={port.name}
logoBuffer={logo.buffer}
rows={convertedRows}
subtotal={subtotal}
managementFee={fee}
total={total}
dateFrom={query.dateFrom}
dateTo={query.dateTo}
rateAvailable={Boolean(eurRate)}
/>,
);
}