From 1bda3ac1aab9c953708896be899bf76b00721a7d Mon Sep 17 00:00:00 2001 From: Kundencenter Date: Tue, 29 Sep 2026 11:25:27 +0200 Subject: [PATCH] fix(invoices): CSV-Formel-Injection im Export verhindern MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- apps/api/src/modules/invoices/index.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/apps/api/src/modules/invoices/index.ts b/apps/api/src/modules/invoices/index.ts index 9adb18f..7d634a5 100644 --- a/apps/api/src/modules/invoices/index.ts +++ b/apps/api/src/modules/invoices/index.ts @@ -93,7 +93,10 @@ function exportFilter(q: z.infer): { sql: string; params: un return { sql: conds.join(' AND '), params }; } 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 STATUS_LABEL: Record = { open: 'Offen', paid: 'Bezahlt', cancelled: 'Storniert' }; @@ -123,7 +126,7 @@ const DATEV_BOOKING_HEADER = [ const pad2 = (n: number) => String(n).padStart(2, '0'); 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 datevQ = (s: string) => `"${s.replace(/"/g, '""')}"`; +const datevQ = (s: string) => `"${csvSafe(s).replace(/"/g, '""')}"`; 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. */ function datevRow(vals: Record): string {