fix(invoices): CSV-Formel-Injection im Export verhindern

Beim Nightbot-Nachtriage-Durchlauf gefunden: Firmenname und Notiz im
Rechnungsjournal-CSV (aus Kunden-/Personal-Eingaben) landeten ungeprüft
in der Zelle. Beginnt ein solcher Wert mit =, +, - oder @, interpretieren
Excel/Sheets ihn beim Öffnen als Formel statt als Text (CSV-Injection).

Zellen mit einem dieser Startzeichen werden jetzt mit einem führenden
Apostroph entschärft (Standard-Gegenmaßnahme), sowohl im generischen
CSV-Export als auch im DATEV-Buchungstext (dort fließt der Firmenname
ebenfalls mit ein).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Kundencenter 2026-09-29 11:25:27 +02:00
parent 12fec5c822
commit 1bda3ac1aa

View file

@ -93,7 +93,10 @@ function exportFilter(q: z.infer<typeof exportQuery>): { sql: string; params: un
return { sql: conds.join(' AND '), params }; return { sql: conds.join(' AND '), params };
} }
const csvNum = (cents: number) => (cents / 100).toFixed(2).replace('.', ','); const csvNum = (cents: number) => (cents / 100).toFixed(2).replace('.', ',');
const csvCell = (s: string): string => (/[;"\n]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s); // Formel-Injection-Schutz (CSV): Zellen, die mit =, +, -, @ oder Tab beginnen, werden von Excel/Sheets sonst als Formel
// ausgeführt. Firmenname/Notiz stammen aus vom Kunden bzw. Personal eingegebenen Feldern – daher immer entschärfen.
const csvSafe = (s: string): string => (/^[=+\-@\t]/.test(s) ? `'${s}` : s);
const csvCell = (s: string): string => { const v = csvSafe(s); return /[;"\n]/.test(v) ? `"${v.replace(/"/g, '""')}"` : v; };
const csvDate = (d: unknown) => (d ? new Date(d as string).toISOString().slice(0, 10) : ''); const csvDate = (d: unknown) => (d ? new Date(d as string).toISOString().slice(0, 10) : '');
const STATUS_LABEL: Record<string, string> = { open: 'Offen', paid: 'Bezahlt', cancelled: 'Storniert' }; const STATUS_LABEL: Record<string, string> = { open: 'Offen', paid: 'Bezahlt', cancelled: 'Storniert' };
@ -123,7 +126,7 @@ const DATEV_BOOKING_HEADER = [
const pad2 = (n: number) => String(n).padStart(2, '0'); const pad2 = (n: number) => String(n).padStart(2, '0');
const datevYmd = (d: Date) => `${d.getUTCFullYear()}${pad2(d.getUTCMonth() + 1)}${pad2(d.getUTCDate())}`; const datevYmd = (d: Date) => `${d.getUTCFullYear()}${pad2(d.getUTCMonth() + 1)}${pad2(d.getUTCDate())}`;
const datevDm = (d: Date) => `${pad2(d.getUTCDate())}${pad2(d.getUTCMonth() + 1)}`; const datevDm = (d: Date) => `${pad2(d.getUTCDate())}${pad2(d.getUTCMonth() + 1)}`;
const datevQ = (s: string) => `"${s.replace(/"/g, '""')}"`; const datevQ = (s: string) => `"${csvSafe(s).replace(/"/g, '""')}"`;
const datevNum = (cents: number) => (cents / 100).toFixed(2).replace('.', ','); const datevNum = (cents: number) => (cents / 100).toFixed(2).replace('.', ',');
/** Eine Buchungszeile mit genau 125 Feldern; nur die übergebenen (1-basierten) Spaltennummern werden gesetzt, der Rest bleibt leer. */ /** Eine Buchungszeile mit genau 125 Feldern; nur die übergebenen (1-basierten) Spaltennummern werden gesetzt, der Rest bleibt leer. */
function datevRow(vals: Record<number, string>): string { function datevRow(vals: Record<number, string>): string {