fix: Race Conditions bei Gutscheincode-Einloesung und Retouren-Erstattung

- Checkout: Gutscheincode-Nutzungslimit wird jetzt innerhalb derselben
  Serializable-Transaktion wie die Bestellanlage frisch nachgeprueft, statt
  nur einmalig vorab. Zwei gleichzeitige Checkouts mit demselben
  maxUses:1-Code konnten sich sonst beide durchsetzen.
- Admin/Retouren: Erstattungsstatus wird jetzt atomar (bedingtes Update,
  ANGENOMMEN -> ERSTATTET) VOR dem Stripe-Aufruf gesetzt statt danach. Ein
  Doppelklick auf "Erstatten" konnte sonst zwei echte Stripe-Erstattungen
  ausloesen. Bei einem fehlschlagenden Stripe-Aufruf wird der Status wieder
  zurueckgesetzt, damit erneut versucht werden kann.

Beide Luecken kamen aus dem Security-Assessment dieser Session.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
This commit is contained in:
Nick SzaunigandClaude Sonnet 5 committed 2026-09-03 23:20:38 +02:00
1 parent b6db9254a9
commit c59913b75b
2 files changed
+107 -38

No files matched your search

+20 -4
View File
@@ -68,8 +68,6 @@ export async function refundReturnRequestAction(formData: FormData): Promise<voi
}, },
}, },
}); });
// Nur eine angenommene Retoure kann erstattet werden — verhindert
// Doppel-Erstattung, falls dieselbe Aktion mehrfach ausgeloest wird.
if (!returnRequest || returnRequest.status !== "ANGENOMMEN") return; if (!returnRequest || returnRequest.status !== "ANGENOMMEN") return;
const paymentIntentId = returnRequest.order.payment?.stripePaymentIntentId; const paymentIntentId = returnRequest.order.payment?.stripePaymentIntentId;
@@ -77,11 +75,29 @@ export async function refundReturnRequestAction(formData: FormData): Promise<voi
fail("/admin/retouren", "Keine Stripe-Zahlung zu dieser Bestellung hinterlegt — bitte manuell erstatten."); fail("/admin/retouren", "Keine Stripe-Zahlung zu dieser Bestellung hinterlegt — bitte manuell erstatten.");
} }
// Status ATOMAR und VOR dem Stripe-Aufruf auf ERSTATTET setzen (bedingt auf
// den noch erwarteten "ANGENOMMEN"-Stand) statt erst danach: der
// findUnique-Check allein ist nicht atomar — bei zwei schnell
// aufeinanderfolgenden Klicks (Doppelklick, zwei offene Tabs) koennten
// sonst beide Aufrufe die Pruefung bestehen und zwei echte
// Stripe-Erstattungen ausloesen. Schlaegt der Stripe-Aufruf unten fehl,
// wird der Status wieder zurueckgesetzt, damit die Erstattung erneut
// versucht werden kann.
const claimed = await prisma.returnRequest.updateMany({
where: { id, status: "ANGENOMMEN" },
data: { status: "ERSTATTET" },
});
if (claimed.count === 0) return;
const stripe = getStripeClient(); const stripe = getStripeClient();
await stripe.refunds.create({ payment_intent: paymentIntentId }); try {
await stripe.refunds.create({ payment_intent: paymentIntentId });
} catch (error: unknown) {
await prisma.returnRequest.update({ where: { id }, data: { status: "ANGENOMMEN" } });
throw error;
}
await prisma.$transaction(async (tx) => { await prisma.$transaction(async (tx) => {
await tx.returnRequest.update({ where: { id }, data: { status: "ERSTATTET" } });
await tx.payment.update({ where: { orderId: returnRequest.orderId }, data: { status: "REFUNDED" } }); await tx.payment.update({ where: { orderId: returnRequest.orderId }, data: { status: "REFUNDED" } });
// Zurueckgesendete Ware wieder als verfuegbaren Bestand fuehren, analog // Zurueckgesendete Ware wieder als verfuegbaren Bestand fuehren, analog
// zur Stornierung im Checkout-Webhook. // zur Stornierung im Checkout-Webhook.
+87 -34
View File
@@ -1,6 +1,7 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { randomBytes } from "node:crypto"; import { randomBytes } from "node:crypto";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import { Prisma } from "@/generated/prisma/client";
import { getStripeClient } from "@/lib/stripe"; import { getStripeClient } from "@/lib/stripe";
import { checkoutSchema } from "@/lib/validation"; import { checkoutSchema } from "@/lib/validation";
import { getProductsByIds } from "@/lib/data"; import { getProductsByIds } from "@/lib/data";
@@ -14,6 +15,18 @@ import { getBaseUrl } from "@/lib/url";
import { getLocale } from "@/i18n/locale"; import { getLocale } from "@/i18n/locale";
class OutOfStockError extends Error {} class OutOfStockError extends Error {}
// Der fruehe pendingUses-Check weiter unten liest ausserhalb jeder Transaktion
// — zwei zeitgleiche Checkouts mit demselben maxUses:1-Code koennten ihn
// beide bestehen, bevor eine der beiden Bestellungen tatsaechlich angelegt
// ist (TOCTOU). Dieser Fehlertyp markiert den verbindlichen Re-Check direkt
// vor dem Anlegen der Bestellung (s.u., innerhalb der Serializable-Transaktion).
class DiscountCodeExhaustedError extends Error {}
// Postgres/MySQL brechen bei "Serializable"-Isolation eine von zwei
// widerspruechlichen, gleichzeitigen Transaktionen mit einem
// Serialisierungsfehler ab, statt still beide durchzulassen — das ist der
// eigentliche Schutz gegen die Race-Condition, der explizite Re-Check oben
// ist nur die freundlichere Fehlermeldung fuer den haeufigeren Fall.
const SERIALIZATION_CONFLICT_CODE = "P2034";
function generateOrderNumber(): string { function generateOrderNumber(): string {
const year = new Date().getFullYear(); const year = new Date().getFullYear();
@@ -173,43 +186,71 @@ export async function POST(request: Request) {
// updateMany (quantity >= bestellt) verhindert Ueberverkauf auch bei // updateMany (quantity >= bestellt) verhindert Ueberverkauf auch bei
// gleichzeitigen Bestellungen — schlaegt eine Position fehl, wird die // gleichzeitigen Bestellungen — schlaegt eine Position fehl, wird die
// gesamte Transaktion (inkl. bereits reservierter Positionen) verworfen. // gesamte Transaktion (inkl. bereits reservierter Positionen) verworfen.
order = await prisma.$transaction(async (tx) => { order = await prisma.$transaction(
for (const item of orderItemsData) { async (tx) => {
const reserved = await tx.inventory.updateMany({ for (const item of orderItemsData) {
where: { productId: item.productId, quantity: { gte: item.quantity } }, const reserved = await tx.inventory.updateMany({
data: { quantity: { decrement: item.quantity } }, where: { productId: item.productId, quantity: { gte: item.quantity } },
}); data: { quantity: { decrement: item.quantity } },
if (reserved.count === 0) { });
throw new OutOfStockError(item.productName); if (reserved.count === 0) {
throw new OutOfStockError(item.productName);
}
} }
}
return tx.order.create({ // Verbindlicher Re-Check direkt vor dem Anlegen der Bestellung: der
data: { // fruehe pendingUses-Check oben ist nur ein schneller Vorabfilter
orderNumber: generateOrderNumber(), // (bessere Fehlermeldung fuer den Normalfall), dieser hier — innerhalb
customerId: customer?.id, // derselben Serializable-Transaktion wie die Bestellanlage, mit einem
locale, // frischen Datenbank-Stand statt der aeusseren (potenziell leicht
fulfillmentMethod: input.fulfillmentMethod, // veralteten) Momentaufnahme — ist der tatsaechlich verbindliche
email: input.email, // Schutz gegen zwei gleichzeitige Checkouts mit demselben knapp
firstName: input.firstName, // begrenzten Code.
lastName: input.lastName, if (discountCode) {
phone: input.phone, const freshCode = await tx.discountCode.findUnique({ where: { id: discountCode.id } });
street: input.street ?? "", if (freshCode && freshCode.maxUses !== null) {
zip: input.zip ?? "", const pendingUses = await tx.order.count({
city: input.city ?? "", where: { discountCodeId: freshCode.id, status: "NEU" },
country: input.country, });
subtotalCents, if (freshCode.usedCount + pendingUses >= freshCode.maxUses) {
shippingCents, throw new DiscountCodeExhaustedError();
discountCents, }
discountCodeId: discountCode?.id, }
totalCents, }
giftMessage: input.giftMessage || null,
items: { return tx.order.create({
create: orderItemsData, data: {
orderNumber: generateOrderNumber(),
customerId: customer?.id,
locale,
fulfillmentMethod: input.fulfillmentMethod,
email: input.email,
firstName: input.firstName,
lastName: input.lastName,
phone: input.phone,
street: input.street ?? "",
zip: input.zip ?? "",
city: input.city ?? "",
country: input.country,
subtotalCents,
shippingCents,
discountCents,
discountCodeId: discountCode?.id,
totalCents,
giftMessage: input.giftMessage || null,
items: {
create: orderItemsData,
},
}, },
}, });
}); },
}); // Serializable statt der DB-Voreinstellung ("Read Committed"): erst
// dadurch erkennt die Datenbank selbst den Konflikt zwischen zwei
// gleichzeitigen Transaktionen, die beide denselben knapp begrenzten
// Gutscheincode einloesen wollen, und bricht eine davon zuverlaessig ab
// (statt beide auf Basis veralteter Zaehlerstaende durchzulassen).
{ isolationLevel: "Serializable" }
);
} catch (error: unknown) { } catch (error: unknown) {
if (error instanceof OutOfStockError) { if (error instanceof OutOfStockError) {
return NextResponse.json( return NextResponse.json(
@@ -219,6 +260,18 @@ export async function POST(request: Request) {
{ status: 409 } { status: 409 }
); );
} }
if (error instanceof DiscountCodeExhaustedError) {
return NextResponse.json(
{ error: "Der Gutscheincode ist ungültig, abgelaufen oder nicht mehr einlösbar." },
{ status: 409 }
);
}
if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === SERIALIZATION_CONFLICT_CODE) {
return NextResponse.json(
{ error: "Kurzzeitig zu viele gleichzeitige Bestellungen. Bitte versuche es in ein paar Sekunden erneut." },
{ status: 409 }
);
}
throw error; throw error;
} }