From cef2248a4532070d18628150f13b13c4ac9a8ce5 Mon Sep 17 00:00:00 2001 From: Nick Szaunig Date: Sat, 26 Sep 2026 17:42:49 +0200 Subject: [PATCH] fix(security): escape untrusted values in transactional email HTML MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Customer input (first name, address, product names), order data, admin fields (company data, carrier, tracking number) and URLs were inserted into email HTML unescaped. A first name such as … ended up as a working link in a genuine shop email, usable for phishing. All variable values in email HTML now go through escapeHtml (lib/escape-html.ts, shared with the withdrawal receipt). Button links only accept http(s), so a javascript: tracking URL is dropped. The plain text part stays unescaped. Tests cover order confirmation, shipment, status mails, layout company data and back-in-stock mails. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/app/api/widerruf/route.ts | 3 +- src/lib/email-templates.test.ts | 75 +++++++++++++++++++++++++++++++++ src/lib/email-templates.ts | 52 +++++++++++++---------- src/lib/escape-html.ts | 14 ++++++ src/lib/withdrawal.test.ts | 3 +- src/lib/withdrawal.ts | 14 ++---- 6 files changed, 126 insertions(+), 35 deletions(-) create mode 100644 src/lib/email-templates.test.ts create mode 100644 src/lib/escape-html.ts diff --git a/src/app/api/widerruf/route.ts b/src/app/api/widerruf/route.ts index 9014111..d089bec 100644 --- a/src/app/api/widerruf/route.ts +++ b/src/app/api/widerruf/route.ts @@ -5,7 +5,8 @@ import { rateLimit, getClientIp } from "@/lib/rate-limit"; import { isTrustedOrigin } from "@/lib/csrf"; import { sendMail } from "@/lib/mail"; import { withdrawalReceiptEmail } from "@/lib/email-templates"; -import { escapeHtml, formatReceivedAt, withdrawalDeclarationLines } from "@/lib/withdrawal"; +import { formatReceivedAt, withdrawalDeclarationLines } from "@/lib/withdrawal"; +import { escapeHtml } from "@/lib/escape-html"; import { getLocale } from "@/i18n/locale"; const WITHDRAWALS_PER_WINDOW = 10; diff --git a/src/lib/email-templates.test.ts b/src/lib/email-templates.test.ts new file mode 100644 index 0000000..8a78a02 --- /dev/null +++ b/src/lib/email-templates.test.ts @@ -0,0 +1,75 @@ +import { describe, expect, it, vi } from "vitest"; + +vi.mock("@/lib/company-info", () => ({ + getCompanyInfo: async () => ({ + name: "WABENHAIN ", + address: { street: "Bühlweg 10", zip: "86450", city: "Altenmünster" }, + }), +})); +vi.mock("@/lib/url", () => ({ getSiteUrl: () => "https://wabenhain.com" })); + +import { + backInStockEmail, + orderConfirmationEmail, + pickupReadyEmail, + returnAcceptedEmail, + shipmentEmail, +} from "./email-templates"; + +const EVIL = `Hier klicken`; +const ESCAPED = "<a href="https://phish.example">Hier klicken</a>"; + +describe("email templates escape untrusted values", () => { + it("order confirmation escapes name, items, address and order number", async () => { + const mail = await orderConfirmationEmail({ + locale: "de", + orderNumber: `WH-1">`, + firstName: EVIL, + fulfillmentMethod: "VERSAND", + items: [{ productName: EVIL, quantity: 1, weightGrams: 500, totalCents: 1200 }], + subtotalCents: 1200, + shippingCents: 0, + totalCents: 1200, + street: EVIL, + zip: "86450", + city: "", + }); + expect(mail.html).not.toContain("https://phish.example\">"); + expect(mail.html).not.toContain("