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]>
97 lines
3.7 KiB
TypeScript
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" } }
|
|
);
|
|
}
|