fix(security): escape untrusted values in transactional email HTML

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 <a href="…">…</a> 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) <[email protected]>
This commit is contained in:
Nick SzaunigandClaude Opus 5.5 committed 2026-09-26 17:42:49 +02:00
1 parent dcfad4e011
commit cef2248a45
6 files changed
+126 -35

No files matched your search

+2 -1
View File
@@ -5,7 +5,8 @@ import { rateLimit, getClientIp } from "@/lib/rate-limit";
import { isTrustedOrigin } from "@/lib/csrf"; import { isTrustedOrigin } from "@/lib/csrf";
import { sendMail } from "@/lib/mail"; import { sendMail } from "@/lib/mail";
import { withdrawalReceiptEmail } from "@/lib/email-templates"; 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"; import { getLocale } from "@/i18n/locale";
const WITHDRAWALS_PER_WINDOW = 10; const WITHDRAWALS_PER_WINDOW = 10;
+75
View File
@@ -0,0 +1,75 @@
import { describe, expect, it, vi } from "vitest";
vi.mock("@/lib/company-info", () => ({
getCompanyInfo: async () => ({
name: "WABENHAIN <b>",
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 = `<a href="https://phish.example">Hier klicken</a>`;
const ESCAPED = "&lt;a href=&quot;https://phish.example&quot;&gt;Hier klicken&lt;/a&gt;";
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"><img src=x>`,
firstName: EVIL,
fulfillmentMethod: "VERSAND",
items: [{ productName: EVIL, quantity: 1, weightGrams: 500, totalCents: 1200 }],
subtotalCents: 1200,
shippingCents: 0,
totalCents: 1200,
street: EVIL,
zip: "86450",
city: "<script>alert(1)</script>",
});
expect(mail.html).not.toContain("https://phish.example\">");
expect(mail.html).not.toContain("<script>");
expect(mail.html).not.toContain("<img src=x>");
expect(mail.html).toContain(ESCAPED);
// Der Text-Teil bleibt lesbar und unmaskiert.
expect(mail.text).toContain(EVIL);
});
it("shipment mail escapes carrier and tracking number and rejects non-http tracking links", async () => {
const mail = await shipmentEmail({
locale: "de",
orderNumber: "WH-1",
firstName: "Erika",
carrier: EVIL,
trackingNumber: "<b>123</b>",
trackingUrl: "javascript:alert(1)",
});
expect(mail.html).toContain(ESCAPED);
expect(mail.html).toContain("&lt;b&gt;123&lt;/b&gt;");
expect(mail.html).not.toContain("javascript:");
});
it("status mails escape the first name and order number", async () => {
const mail = await returnAcceptedEmail({ locale: "en", orderNumber: "<i>1</i>", firstName: EVIL });
expect(mail.html).toContain(ESCAPED);
expect(mail.html).toContain("&lt;i&gt;1&lt;/i&gt;");
});
it("escapes admin-maintained company data in the layout", async () => {
const mail = await pickupReadyEmail({ locale: "de", orderNumber: "WH-1", firstName: "Erika" });
expect(mail.html).toContain("WABENHAIN &lt;b&gt;");
});
it("escapes product names in back-in-stock mails", async () => {
const mail = await backInStockEmail({ locale: "de", name: EVIL, slug: "honig" });
expect(mail.html).toContain(ESCAPED);
expect(mail.html).not.toContain("https://phish.example\">");
});
});
+30 -22
View File
@@ -2,6 +2,7 @@ import { formatPrice } from "@/lib/format";
import { getSiteUrl } from "@/lib/url"; import { getSiteUrl } from "@/lib/url";
import { getCompanyInfo } from "@/lib/company-info"; import { getCompanyInfo } from "@/lib/company-info";
import { getEmailStrings } from "@/lib/email-strings"; import { getEmailStrings } from "@/lib/email-strings";
import { escapeHtml } from "@/lib/escape-html";
import { withdrawalReceiptEmailContent, type WithdrawalDeclarationInput } from "@/lib/withdrawal"; import { withdrawalReceiptEmailContent, type WithdrawalDeclarationInput } from "@/lib/withdrawal";
import type { Locale } from "@/i18n/config"; import type { Locale } from "@/i18n/config";
@@ -16,13 +17,18 @@ interface EmailContent {
* Inline-Styles und Tabellen-Layout statt Tailwind/externem CSS, da viele * Inline-Styles und Tabellen-Layout statt Tailwind/externem CSS, da viele
* E-Mail-Clients (Outlook, ältere Gmail-Ansichten) beides nicht zuverlaessig * E-Mail-Clients (Outlook, ältere Gmail-Ansichten) beides nicht zuverlaessig
* unterstuetzen. * unterstuetzen.
*
* Jeder variable Wert (Kundeneingaben, Bestelldaten, Admin-Felder) wird vor
* dem Einsetzen ins HTML mit escapeHtml maskiert — sonst liesse sich ueber
* z. B. den Vornamen HTML samt Phishing-Link in eine echte Shop-Mail
* einschleusen. Die Text-Variante bleibt unmaskiert (kein HTML).
*/ */
async function emailLayout(locale: Locale, preheader: string, bodyHtml: string): Promise<string> { async function emailLayout(locale: Locale, preheader: string, bodyHtml: string): Promise<string> {
const company = await getCompanyInfo(); const company = await getCompanyInfo();
return `<!doctype html> return `<!doctype html>
<html lang="${locale}"> <html lang="${locale}">
<body style="margin:0;padding:0;background-color:#FAF8F3;font-family:Arial,Helvetica,sans-serif;color:#242424;"> <body style="margin:0;padding:0;background-color:#FAF8F3;font-family:Arial,Helvetica,sans-serif;color:#242424;">
<span style="display:none;font-size:1px;color:#FAF8F3;line-height:1px;">${preheader}</span> <span style="display:none;font-size:1px;color:#FAF8F3;line-height:1px;">${escapeHtml(preheader)}</span>
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background-color:#FAF8F3;padding:32px 16px;"> <table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background-color:#FAF8F3;padding:32px 16px;">
<tr> <tr>
<td align="center"> <td align="center">
@@ -40,7 +46,7 @@ async function emailLayout(locale: Locale, preheader: string, bodyHtml: string):
</tr> </tr>
<tr> <tr>
<td style="padding:20px 32px;background-color:#F4EFE5;font-size:12px;color:#666;"> <td style="padding:20px 32px;background-color:#F4EFE5;font-size:12px;color:#666;">
${company.name} · ${company.address.street}, ${company.address.zip} ${company.address.city} ${escapeHtml(company.name)} · ${escapeHtml(company.address.street)}, ${escapeHtml(company.address.zip)} ${escapeHtml(company.address.city)}
</td> </td>
</tr> </tr>
</table> </table>
@@ -51,8 +57,10 @@ async function emailLayout(locale: Locale, preheader: string, bodyHtml: string):
</html>`; </html>`;
} }
/** Nur http(s)-Links — ein "javascript:"-Link aus einem Admin-Feld (Tracking) wird weggelassen. */
function button(url: string, label: string): string { function button(url: string, label: string): string {
return `<a href="${url}" style="display:inline-block;margin-top:16px;padding:12px 24px;background-color:#24352B;color:#F4EFE5;text-decoration:none;font-size:14px;letter-spacing:1px;">${label}</a>`; if (!/^https?:\/\//i.test(url)) return "";
return `<a href="${escapeHtml(url)}" style="display:inline-block;margin-top:16px;padding:12px 24px;background-color:#24352B;color:#F4EFE5;text-decoration:none;font-size:14px;letter-spacing:1px;">${escapeHtml(label)}</a>`;
} }
interface OrderConfirmationInput { interface OrderConfirmationInput {
@@ -77,13 +85,13 @@ export async function orderConfirmationEmail(order: OrderConfirmationInput): Pro
const rows = order.items const rows = order.items
.map( .map(
(item) => (item) =>
`<tr><td style="padding:6px 0;">${item.quantity} × ${item.productName} (${item.weightGrams} g)</td><td style="padding:6px 0;text-align:right;">${formatPrice(item.totalCents, "EUR", order.locale)}</td></tr>` `<tr><td style="padding:6px 0;">${item.quantity} × ${escapeHtml(item.productName)} (${item.weightGrams} g)</td><td style="padding:6px 0;text-align:right;">${formatPrice(item.totalCents, "EUR", order.locale)}</td></tr>`
) )
.join(""); .join("");
const deliveryHtml = const deliveryHtml =
order.fulfillmentMethod === "VERSAND" order.fulfillmentMethod === "VERSAND"
? `<p style="margin:4px 0;">${s.deliveryTo}<br>${order.street}<br>${order.zip} ${order.city}</p>` ? `<p style="margin:4px 0;">${s.deliveryTo}<br>${escapeHtml(order.street)}<br>${escapeHtml(order.zip)} ${escapeHtml(order.city)}</p>`
: `<p style="margin:4px 0;">${s.pickupNote}</p>`; : `<p style="margin:4px 0;">${s.pickupNote}</p>`;
const giftHtml = order.giftMessage const giftHtml = order.giftMessage
@@ -96,8 +104,8 @@ export async function orderConfirmationEmail(order: OrderConfirmationInput): Pro
order.locale, order.locale,
s.preheader(order.orderNumber), s.preheader(order.orderNumber),
` `
<h1 style="font-size:20px;color:#24352B;margin:0 0 12px;">${s.heading(order.firstName)}</h1> <h1 style="font-size:20px;color:#24352B;margin:0 0 12px;">${s.heading(escapeHtml(order.firstName))}</h1>
<p style="margin:0 0 20px;line-height:1.5;">${s.intro(order.orderNumber)}</p> <p style="margin:0 0 20px;line-height:1.5;">${s.intro(escapeHtml(order.orderNumber))}</p>
<table role="presentation" width="100%" style="font-size:14px;border-top:1px solid #eee;border-bottom:1px solid #eee;padding:8px 0;"> <table role="presentation" width="100%" style="font-size:14px;border-top:1px solid #eee;border-bottom:1px solid #eee;padding:8px 0;">
${rows} ${rows}
</table> </table>
@@ -226,7 +234,7 @@ export async function shipmentEmail(order: ShipmentEmailInput): Promise<EmailCon
const trackingHtml = hasTracking const trackingHtml = hasTracking
? ` ? `
<p style="margin:16px 0 0;line-height:1.5;"> <p style="margin:16px 0 0;line-height:1.5;">
${order.carrier}: <strong>${order.trackingNumber}</strong> ${escapeHtml(order.carrier ?? "")}: <strong>${escapeHtml(order.trackingNumber ?? "")}</strong>
</p> </p>
${order.trackingUrl ? button(order.trackingUrl, s.trackButton) : ""} ${order.trackingUrl ? button(order.trackingUrl, s.trackButton) : ""}
` `
@@ -235,8 +243,8 @@ export async function shipmentEmail(order: ShipmentEmailInput): Promise<EmailCon
order.locale, order.locale,
s.preheader(order.orderNumber), s.preheader(order.orderNumber),
` `
<h1 style="font-size:20px;color:#24352B;margin:0 0 12px;">${s.heading(order.firstName)}</h1> <h1 style="font-size:20px;color:#24352B;margin:0 0 12px;">${s.heading(escapeHtml(order.firstName))}</h1>
<p style="margin:0 0 8px;line-height:1.5;">${s.intro(order.orderNumber)}</p> <p style="margin:0 0 8px;line-height:1.5;">${s.intro(escapeHtml(order.orderNumber))}</p>
${trackingHtml} ${trackingHtml}
` `
); );
@@ -257,10 +265,10 @@ export async function pickupReadyEmail(order: OrderStatusUpdateInput): Promise<E
order.locale, order.locale,
s.preheader(order.orderNumber), s.preheader(order.orderNumber),
` `
<h1 style="font-size:20px;color:#24352B;margin:0 0 12px;">${s.heading(order.firstName)}</h1> <h1 style="font-size:20px;color:#24352B;margin:0 0 12px;">${s.heading(escapeHtml(order.firstName))}</h1>
<p style="margin:0 0 8px;line-height:1.5;">${s.intro(order.orderNumber)}</p> <p style="margin:0 0 8px;line-height:1.5;">${s.intro(escapeHtml(order.orderNumber))}</p>
<p style="margin:16px 0 0;line-height:1.5;"> <p style="margin:16px 0 0;line-height:1.5;">
${company.address.street}<br>${company.address.zip} ${company.address.city} ${escapeHtml(company.address.street)}<br>${escapeHtml(company.address.zip)} ${escapeHtml(company.address.city)}
</p> </p>
` `
); );
@@ -291,7 +299,7 @@ export async function backInStockEmail(product: BackInStockInput): Promise<Email
s.preheader(product.name), s.preheader(product.name),
` `
<h1 style="font-size:20px;color:#24352B;margin:0 0 12px;">${s.heading}</h1> <h1 style="font-size:20px;color:#24352B;margin:0 0 12px;">${s.heading}</h1>
<p style="margin:0;line-height:1.5;">${s.intro(product.name)}</p> <p style="margin:0;line-height:1.5;">${s.intro(escapeHtml(product.name))}</p>
${button(url, s.button)} ${button(url, s.button)}
` `
); );
@@ -322,8 +330,8 @@ export async function returnAcceptedEmail(order: OrderStatusUpdateInput): Promis
order.locale, order.locale,
s.preheader(order.orderNumber), s.preheader(order.orderNumber),
` `
<h1 style="font-size:20px;color:#24352B;margin:0 0 12px;">${s.heading(order.firstName)}</h1> <h1 style="font-size:20px;color:#24352B;margin:0 0 12px;">${s.heading(escapeHtml(order.firstName))}</h1>
<p style="margin:0;line-height:1.5;">${s.intro(order.orderNumber)}</p> <p style="margin:0;line-height:1.5;">${s.intro(escapeHtml(order.orderNumber))}</p>
` `
); );
return { return {
@@ -339,8 +347,8 @@ export async function returnRejectedEmail(order: OrderStatusUpdateInput): Promis
order.locale, order.locale,
s.preheader(order.orderNumber), s.preheader(order.orderNumber),
` `
<h1 style="font-size:20px;color:#24352B;margin:0 0 12px;">${s.heading(order.firstName)}</h1> <h1 style="font-size:20px;color:#24352B;margin:0 0 12px;">${s.heading(escapeHtml(order.firstName))}</h1>
<p style="margin:0;line-height:1.5;">${s.intro(order.orderNumber)}</p> <p style="margin:0;line-height:1.5;">${s.intro(escapeHtml(order.orderNumber))}</p>
` `
); );
return { return {
@@ -356,8 +364,8 @@ export async function returnRefundedEmail(order: OrderStatusUpdateInput): Promis
order.locale, order.locale,
s.preheader(order.orderNumber), s.preheader(order.orderNumber),
` `
<h1 style="font-size:20px;color:#24352B;margin:0 0 12px;">${s.heading(order.firstName)}</h1> <h1 style="font-size:20px;color:#24352B;margin:0 0 12px;">${s.heading(escapeHtml(order.firstName))}</h1>
<p style="margin:0;line-height:1.5;">${s.intro(order.orderNumber)}</p> <p style="margin:0;line-height:1.5;">${s.intro(escapeHtml(order.orderNumber))}</p>
` `
); );
return { return {
@@ -377,8 +385,8 @@ export async function paymentFailedEmail(order: OrderStatusUpdateInput): Promise
order.locale, order.locale,
s.preheader(order.orderNumber), s.preheader(order.orderNumber),
` `
<h1 style="font-size:20px;color:#24352B;margin:0 0 12px;">${s.heading(order.firstName)}</h1> <h1 style="font-size:20px;color:#24352B;margin:0 0 12px;">${s.heading(escapeHtml(order.firstName))}</h1>
<p style="margin:0 0 8px;line-height:1.5;">${s.intro(order.orderNumber)}</p> <p style="margin:0 0 8px;line-height:1.5;">${s.intro(escapeHtml(order.orderNumber))}</p>
<p style="margin:0;line-height:1.5;">${s.retryNote}</p> <p style="margin:0;line-height:1.5;">${s.retryNote}</p>
<div style="margin-top:20px;">${button(shopUrl, s.button)}</div> <div style="margin-top:20px;">${button(shopUrl, s.button)}</div>
` `
+14
View File
@@ -0,0 +1,14 @@
/**
* Maskiert Text fuer die Ausgabe in HTML (Element-Inhalt und Attributwerte in
* doppelten oder einfachen Anfuehrungszeichen). Pflicht fuer JEDEN Wert aus
* Nutzereingaben, Datenbank oder Admin-Feldern, der in selbst gebautes HTML
* wandert — vor allem in E-Mails, wo React nicht automatisch maskiert.
*/
export function escapeHtml(value: string): string {
return value
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}
+2 -1
View File
@@ -1,5 +1,6 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { escapeHtml, formatReceivedAt, withdrawalDeclarationLines, withdrawalReceiptEmailContent } from "./withdrawal"; import { formatReceivedAt, withdrawalDeclarationLines, withdrawalReceiptEmailContent } from "./withdrawal";
import { escapeHtml } from "./escape-html";
const input = { const input = {
name: "Erika Mustermann", name: "Erika Mustermann",
+3 -11
View File
@@ -1,4 +1,5 @@
import type { Locale } from "@/i18n/config"; import type { Locale } from "@/i18n/config";
import { escapeHtml } from "@/lib/escape-html";
/** /**
* Elektronische Widerrufsfunktion (§ 356a BGB): Wortlaut der Erklaerung und * Elektronische Widerrufsfunktion (§ 356a BGB): Wortlaut der Erklaerung und
@@ -18,7 +19,7 @@ export interface WithdrawalDeclarationInput {
export interface WithdrawalReceiptEmail { export interface WithdrawalReceiptEmail {
subject: string; subject: string;
preheader: string; preheader: string;
/** Inhalt fuer den gemeinsamen Mail-Rahmen (emailLayout), bereits maskiert. */ /** Inhalt fuer den gemeinsamen Mail-Rahmen (emailLayout), bereits maskiert. preheader maskiert emailLayout selbst. */
bodyHtml: string; bodyHtml: string;
text: string; text: string;
} }
@@ -83,15 +84,6 @@ export function withdrawalDeclarationLines(input: WithdrawalDeclarationInput, lo
]; ];
} }
export function escapeHtml(value: string): string {
return value
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}
/** Zeilenumbrueche im Betreff wuerden sonst als weitere Mail-Header gelesen (Header-Injection). */ /** Zeilenumbrueche im Betreff wuerden sonst als weitere Mail-Header gelesen (Header-Injection). */
function singleLine(value: string): string { function singleLine(value: string): string {
return value.replace(/[\r\n]+/g, " "); return value.replace(/[\r\n]+/g, " ");
@@ -117,7 +109,7 @@ export function withdrawalReceiptEmailContent(
const text = [s.heading, "", s.intro, "", ...lines, "", `${s.receivedAt}: ${when}`, "", s.next].join("\n"); const text = [s.heading, "", s.intro, "", ...lines, "", `${s.receivedAt}: ${when}`, "", s.next].join("\n");
return { return {
subject: singleLine(s.subject(input.orderNumber)), subject: singleLine(s.subject(input.orderNumber)),
preheader: escapeHtml(singleLine(`${s.receivedAt}: ${when}`)), preheader: singleLine(`${s.receivedAt}: ${when}`),
bodyHtml, bodyHtml,
text, text,
}; };