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:
1 parent
dcfad4e011
commit
cef2248a45
6 files changed
+126
-35
No files matched your search
@@ -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 = "<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"><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("<b>123</b>");
|
||||
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("<i>1</i>");
|
||||
});
|
||||
|
||||
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 <b>");
|
||||
});
|
||||
|
||||
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
@@ -2,6 +2,7 @@ import { formatPrice } from "@/lib/format";
|
||||
import { getSiteUrl } from "@/lib/url";
|
||||
import { getCompanyInfo } from "@/lib/company-info";
|
||||
import { getEmailStrings } from "@/lib/email-strings";
|
||||
import { escapeHtml } from "@/lib/escape-html";
|
||||
import { withdrawalReceiptEmailContent, type WithdrawalDeclarationInput } from "@/lib/withdrawal";
|
||||
import type { Locale } from "@/i18n/config";
|
||||
|
||||
@@ -16,13 +17,18 @@ interface EmailContent {
|
||||
* Inline-Styles und Tabellen-Layout statt Tailwind/externem CSS, da viele
|
||||
* E-Mail-Clients (Outlook, ältere Gmail-Ansichten) beides nicht zuverlaessig
|
||||
* 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> {
|
||||
const company = await getCompanyInfo();
|
||||
return `<!doctype html>
|
||||
<html lang="${locale}">
|
||||
<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;">
|
||||
<tr>
|
||||
<td align="center">
|
||||
@@ -40,7 +46,7 @@ async function emailLayout(locale: Locale, preheader: string, bodyHtml: string):
|
||||
</tr>
|
||||
<tr>
|
||||
<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>
|
||||
</tr>
|
||||
</table>
|
||||
@@ -51,8 +57,10 @@ async function emailLayout(locale: Locale, preheader: string, bodyHtml: string):
|
||||
</html>`;
|
||||
}
|
||||
|
||||
/** Nur http(s)-Links — ein "javascript:"-Link aus einem Admin-Feld (Tracking) wird weggelassen. */
|
||||
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 {
|
||||
@@ -77,13 +85,13 @@ export async function orderConfirmationEmail(order: OrderConfirmationInput): Pro
|
||||
const rows = order.items
|
||||
.map(
|
||||
(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("");
|
||||
|
||||
const deliveryHtml =
|
||||
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>`;
|
||||
|
||||
const giftHtml = order.giftMessage
|
||||
@@ -96,8 +104,8 @@ export async function orderConfirmationEmail(order: OrderConfirmationInput): Pro
|
||||
order.locale,
|
||||
s.preheader(order.orderNumber),
|
||||
`
|
||||
<h1 style="font-size:20px;color:#24352B;margin:0 0 12px;">${s.heading(order.firstName)}</h1>
|
||||
<p style="margin:0 0 20px;line-height:1.5;">${s.intro(order.orderNumber)}</p>
|
||||
<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(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;">
|
||||
${rows}
|
||||
</table>
|
||||
@@ -226,7 +234,7 @@ export async function shipmentEmail(order: ShipmentEmailInput): Promise<EmailCon
|
||||
const trackingHtml = hasTracking
|
||||
? `
|
||||
<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>
|
||||
${order.trackingUrl ? button(order.trackingUrl, s.trackButton) : ""}
|
||||
`
|
||||
@@ -235,8 +243,8 @@ export async function shipmentEmail(order: ShipmentEmailInput): Promise<EmailCon
|
||||
order.locale,
|
||||
s.preheader(order.orderNumber),
|
||||
`
|
||||
<h1 style="font-size:20px;color:#24352B;margin:0 0 12px;">${s.heading(order.firstName)}</h1>
|
||||
<p style="margin:0 0 8px;line-height:1.5;">${s.intro(order.orderNumber)}</p>
|
||||
<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(escapeHtml(order.orderNumber))}</p>
|
||||
${trackingHtml}
|
||||
`
|
||||
);
|
||||
@@ -257,10 +265,10 @@ export async function pickupReadyEmail(order: OrderStatusUpdateInput): Promise<E
|
||||
order.locale,
|
||||
s.preheader(order.orderNumber),
|
||||
`
|
||||
<h1 style="font-size:20px;color:#24352B;margin:0 0 12px;">${s.heading(order.firstName)}</h1>
|
||||
<p style="margin:0 0 8px;line-height:1.5;">${s.intro(order.orderNumber)}</p>
|
||||
<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(escapeHtml(order.orderNumber))}</p>
|
||||
<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>
|
||||
`
|
||||
);
|
||||
@@ -291,7 +299,7 @@ export async function backInStockEmail(product: BackInStockInput): Promise<Email
|
||||
s.preheader(product.name),
|
||||
`
|
||||
<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)}
|
||||
`
|
||||
);
|
||||
@@ -322,8 +330,8 @@ export async function returnAcceptedEmail(order: OrderStatusUpdateInput): Promis
|
||||
order.locale,
|
||||
s.preheader(order.orderNumber),
|
||||
`
|
||||
<h1 style="font-size:20px;color:#24352B;margin:0 0 12px;">${s.heading(order.firstName)}</h1>
|
||||
<p style="margin:0;line-height:1.5;">${s.intro(order.orderNumber)}</p>
|
||||
<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(escapeHtml(order.orderNumber))}</p>
|
||||
`
|
||||
);
|
||||
return {
|
||||
@@ -339,8 +347,8 @@ export async function returnRejectedEmail(order: OrderStatusUpdateInput): Promis
|
||||
order.locale,
|
||||
s.preheader(order.orderNumber),
|
||||
`
|
||||
<h1 style="font-size:20px;color:#24352B;margin:0 0 12px;">${s.heading(order.firstName)}</h1>
|
||||
<p style="margin:0;line-height:1.5;">${s.intro(order.orderNumber)}</p>
|
||||
<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(escapeHtml(order.orderNumber))}</p>
|
||||
`
|
||||
);
|
||||
return {
|
||||
@@ -356,8 +364,8 @@ export async function returnRefundedEmail(order: OrderStatusUpdateInput): Promis
|
||||
order.locale,
|
||||
s.preheader(order.orderNumber),
|
||||
`
|
||||
<h1 style="font-size:20px;color:#24352B;margin:0 0 12px;">${s.heading(order.firstName)}</h1>
|
||||
<p style="margin:0;line-height:1.5;">${s.intro(order.orderNumber)}</p>
|
||||
<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(escapeHtml(order.orderNumber))}</p>
|
||||
`
|
||||
);
|
||||
return {
|
||||
@@ -377,8 +385,8 @@ export async function paymentFailedEmail(order: OrderStatusUpdateInput): Promise
|
||||
order.locale,
|
||||
s.preheader(order.orderNumber),
|
||||
`
|
||||
<h1 style="font-size:20px;color:#24352B;margin:0 0 12px;">${s.heading(order.firstName)}</h1>
|
||||
<p style="margin:0 0 8px;line-height:1.5;">${s.intro(order.orderNumber)}</p>
|
||||
<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(escapeHtml(order.orderNumber))}</p>
|
||||
<p style="margin:0;line-height:1.5;">${s.retryNote}</p>
|
||||
<div style="margin-top:20px;">${button(shopUrl, s.button)}</div>
|
||||
`
|
||||
|
||||
@@ -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, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
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 = {
|
||||
name: "Erika Mustermann",
|
||||
|
||||
+3
-11
@@ -1,4 +1,5 @@
|
||||
import type { Locale } from "@/i18n/config";
|
||||
import { escapeHtml } from "@/lib/escape-html";
|
||||
|
||||
/**
|
||||
* Elektronische Widerrufsfunktion (§ 356a BGB): Wortlaut der Erklaerung und
|
||||
@@ -18,7 +19,7 @@ export interface WithdrawalDeclarationInput {
|
||||
export interface WithdrawalReceiptEmail {
|
||||
subject: 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;
|
||||
text: string;
|
||||
}
|
||||
@@ -83,15 +84,6 @@ export function withdrawalDeclarationLines(input: WithdrawalDeclarationInput, lo
|
||||
];
|
||||
}
|
||||
|
||||
export function escapeHtml(value: string): string {
|
||||
return value
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
/** Zeilenumbrueche im Betreff wuerden sonst als weitere Mail-Header gelesen (Header-Injection). */
|
||||
function singleLine(value: string): string {
|
||||
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");
|
||||
return {
|
||||
subject: singleLine(s.subject(input.orderNumber)),
|
||||
preheader: escapeHtml(singleLine(`${s.receivedAt}: ${when}`)),
|
||||
preheader: singleLine(`${s.receivedAt}: ${when}`),
|
||||
bodyHtml,
|
||||
text,
|
||||
};
|
||||
|
||||
Reference in new issue
Block a user