From 4827c8fa6122b3cc1ef3cdb0b83aeefcd205f0b2 Mon Sep 17 00:00:00 2001 From: Nick Szaunig Date: Mon, 12 Oct 2026 06:57:29 +0200 Subject: [PATCH] fix(invoice): Befunde aus dem Sicherheitsreview - E-Rechnung: in XML verbotene Steuerzeichen aus Namen/Anschriften entfernen, sonst waere das eingebettete XML bei einem einzigen Zeichen ungueltig. - Rechnungskopie: genau eine Adresse; eine Liste (a@b.de,c@d.de) wird beim Speichern abgelehnt und nie an nodemailer gegeben. - Hinweis bei fehlgeschlagener Stornorechnung korrigiert (ein erneutes Stornieren ist nicht moeglich, die Stripe-Erstattung holt sie nach). Co-Authored-By: Claude Opus 5.5 (1M context) --- src/app/admin/actions/content.ts | 3 ++- src/app/admin/actions/orders.ts | 2 +- src/lib/einvoice/cii.test.ts | 4 ++++ src/lib/einvoice/cii.ts | 6 ++++++ src/lib/invoice-mail.test.ts | 5 +++++ src/lib/invoice-mail.ts | 4 +++- 6 files changed, 21 insertions(+), 3 deletions(-) diff --git a/src/app/admin/actions/content.ts b/src/app/admin/actions/content.ts index fb41d6b..f718baf 100644 --- a/src/app/admin/actions/content.ts +++ b/src/app/admin/actions/content.ts @@ -67,7 +67,8 @@ export async function updateSiteContentAction(formData: FormData): Promise } // Die Rechnungskopie geht an genau diese Adresse: leer (keine Kopie) oder // eine echte Adresse, sonst verschwaenden die Kopien stillschweigend. - if (def.key === INVOICE_COPY_KEY && valueDe.trim() !== "" && normalizeContactEmail(valueDe) === null) { + // Genau eine Adresse: "a@b.de, c@d.de" waere fuer nodemailer eine Liste. + if (def.key === INVOICE_COPY_KEY && valueDe.trim() !== "" && !/^[^,;]+$/.test(normalizeContactEmail(valueDe) ?? "")) { return fail( settingsPathForGroup(group), "„Rechnungskopie an“ ist keine gültige E-Mail-Adresse. Bitte z. B. buchhaltung@wabenhain.com eintragen oder das Feld leer lassen." diff --git a/src/app/admin/actions/orders.ts b/src/app/admin/actions/orders.ts index c433ff1..c878215 100644 --- a/src/app/admin/actions/orders.ts +++ b/src/app/admin/actions/orders.ts @@ -328,7 +328,7 @@ export async function updateOrderStatusAction(formData: FormData): Promise await issueCancellationInvoice(orderId, "STORNIERUNG"); } catch (error: unknown) { console.error(`[order-cancel] Stornorechnung fuer ${result.orderNumber} fehlgeschlagen:`, error); - stornoWarnings.push("Die Stornorechnung konnte nicht erstellt werden — bitte die Stornierung erneut auslösen oder den Support informieren."); + stornoWarnings.push("Die Stornorechnung konnte nicht erstellt werden. Sie entsteht spätestens mit der Stripe-Erstattung; sonst bitte den Support informieren."); } if (stornoWarnings.length > 0) { diff --git a/src/lib/einvoice/cii.test.ts b/src/lib/einvoice/cii.test.ts index 3b92d09..a927fd8 100644 --- a/src/lib/einvoice/cii.test.ts +++ b/src/lib/einvoice/cii.test.ts @@ -168,4 +168,8 @@ describe("Hilfsfunktionen", () => { test("escapeXml maskiert alle fuenf Sonderzeichen", () => { expect(escapeXml(`'&'`)).toBe("<a href="x">'&'</a>"); }); + + test("escapeXml entfernt in XML verbotene Steuerzeichen, behaelt Tab und Zeilenumbruch", () => { + expect(escapeXml("Er\u0001ika\u000B\u001F Muster\tfrau\n")).toBe("Erika Muster\tfrau\n"); + }); }); diff --git a/src/lib/einvoice/cii.ts b/src/lib/einvoice/cii.ts index 4738799..86fc6a3 100644 --- a/src/lib/einvoice/cii.ts +++ b/src/lib/einvoice/cii.ts @@ -80,8 +80,14 @@ export interface EInvoiceInput { export type EInvoiceResult = { ok: true; xml: string } | { ok: false; reason: string }; +/** + * Maskiert Text fuer das XML. Steuerzeichen, die XML 1.0 gar nicht erlaubt, + * fallen weg: Namen und Anschriften tippt die Kundschaft selbst, und ein + * einziges solches Zeichen machte das eingebettete XML ungueltig. + */ export function escapeXml(value: string): string { return value + .replace(/[\u0000-\u0008\u000B\u000C\u000E-\u001F￾￿]/g, "") .replace(/&/g, "&") .replace(//g, ">") diff --git a/src/lib/invoice-mail.test.ts b/src/lib/invoice-mail.test.ts index edacdc2..b5ae830 100644 --- a/src/lib/invoice-mail.test.ts +++ b/src/lib/invoice-mail.test.ts @@ -48,6 +48,11 @@ describe("Rechnungskopie", () => { ); }); + test("eine Adressliste wird nicht verwendet", async () => { + values[INVOICE_COPY_KEY] = "buchhaltung@wabenhain.com,fremd@example.org"; + expect(await getInvoiceCopyAddress()).toBeNull(); + }); + test("maskiert Kundendaten im HTML-Teil", () => { const { html, text } = invoiceCopyContent(copy); expect(html).toContain("Erika <Musterfrau>"); diff --git a/src/lib/invoice-mail.ts b/src/lib/invoice-mail.ts index 808506b..6c64392 100644 --- a/src/lib/invoice-mail.ts +++ b/src/lib/invoice-mail.ts @@ -18,7 +18,9 @@ export async function getInvoiceCopyAddress(): Promise { try { const values = await getContentValues([INVOICE_COPY_KEY], "de"); const raw = values[INVOICE_COPY_KEY]?.trim() ?? ""; - return raw ? normalizeContactEmail(raw) : null; + // Genau eine Adresse (siehe Pruefung beim Speichern); eine Liste nie an nodemailer weiterreichen. + const address = raw ? normalizeContactEmail(raw) : null; + return address && !/[,;]/.test(address) ? address : null; } catch (error: unknown) { console.error("[invoice-mail] Adresse fuer die Rechnungskopie nicht lesbar:", error); return null;