Files
Wabenhain/src/app/api/widerruf/route.ts
T
Nick SzaunigandClaude Opus 5.5 cef2248a45 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]>
2026-09-26 17:42:49 +02:00

97 lines
3.7 KiB
TypeScript

import { NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import { withdrawalSchema } from "@/lib/validation";
import { rateLimit, getClientIp } from "@/lib/rate-limit";
import { isTrustedOrigin } from "@/lib/csrf";
import { sendMail } from "@/lib/mail";
import { withdrawalReceiptEmail } from "@/lib/email-templates";
import { formatReceivedAt, withdrawalDeclarationLines } from "@/lib/withdrawal";
import { escapeHtml } from "@/lib/escape-html";
import { getLocale } from "@/i18n/locale";
const WITHDRAWALS_PER_WINDOW = 10;
const WINDOW_MS = 60 * 60 * 1000;
/**
* Elektronische Widerrufsfunktion (§ 356a BGB), zweite Stufe "Widerruf
* bestaetigen". Bewusst OHNE Turnstile: eine fehlschlagende Pruefung duerfte
* die Ausuebung eines gesetzlichen Rechts nicht verhindern. Missbrauch
* begrenzen Herkunftspruefung und Rate-Limit.
*
* Die Antwort ist fuer passende und unpassende Bestellnummern identisch —
* sonst liessen sich Bestellnummern samt E-Mail-Adresse durchprobieren. Die
* Erklaerung wird in jedem Fall gespeichert: sie ist mit dem Zugang wirksam
* abgegeben, die Zuordnung zur Bestellung ist unsere Aufgabe (Admin).
*/
export async function POST(request: Request) {
if (!isTrustedOrigin(request)) {
return NextResponse.json({ error: "Ungültige Anfrage." }, { status: 403 });
}
const ip = await getClientIp();
if (!rateLimit(`withdrawal:${ip}`, WITHDRAWALS_PER_WINDOW, WINDOW_MS)) {
return NextResponse.json(
{ error: "Zu viele Anfragen. Bitte versuche es später erneut oder schreib uns eine E-Mail." },
{ status: 429 }
);
}
const body: unknown = await request.json().catch(() => null);
const parsed = withdrawalSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json(
{ error: "Bitte überprüfe deine Angaben.", issues: parsed.error.flatten() },
{ status: 400 }
);
}
const input = parsed.data;
const locale = await getLocale();
const order = await prisma.order.findUnique({
where: { orderNumber: input.orderNumber },
select: { id: true, email: true },
});
const orderId = order && order.email.trim().toLowerCase() === input.email ? order.id : null;
const declaration = await prisma.withdrawalDeclaration.create({
data: { ...input, locale, orderId },
});
const receipt = await withdrawalReceiptEmail(input, declaration.receivedAt, locale);
const confirmationSent = await sendMail({ to: input.email, ...receipt });
if (confirmationSent) {
await prisma.withdrawalDeclaration.update({
where: { id: declaration.id },
data: { confirmationSentAt: new Date() },
});
}
// Interne Benachrichtigung: ein Widerruf hat eine 14-Tage-Frist fuer die
// Erstattung und darf nicht bis zum naechsten Blick ins Admin liegen bleiben.
const adminEmail = process.env.ADMIN_EMAIL;
if (adminEmail) {
const noticeLines = [
...withdrawalDeclarationLines(input, "de"),
"",
`Eingegangen am: ${formatReceivedAt(declaration.receivedAt, "de")}`,
orderId ? "Automatisch der Bestellung zugeordnet." : "Keine passende Bestellung gefunden — bitte manuell prüfen.",
`Eingangsbestätigung an Kundschaft: ${confirmationSent ? "versendet" : "NICHT versendet"}`,
];
await sendMail({
to: adminEmail,
subject: `Neuer Widerruf zu ${input.orderNumber.replace(/[\r\n]+/g, " ")}`,
text: noticeLines.join("\n"),
html: `<p style="font-family:Arial,sans-serif;line-height:1.6;">${noticeLines.map(escapeHtml).join("<br>")}</p>`,
});
}
return NextResponse.json(
{
receivedAt: formatReceivedAt(declaration.receivedAt, locale),
lines: withdrawalDeclarationLines(input, locale),
confirmationSent,
},
{ headers: { "Cache-Control": "no-store" } }
);
}