From c59913b75b0ad1d49ff4d2c63fadbd1910014cf6 Mon Sep 17 00:00:00 2001 From: Nick Szaunig Date: Thu, 3 Sep 2026 23:20:38 +0200 Subject: [PATCH] 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 --- src/app/admin/retouren/actions.ts | 24 +++++- src/app/api/checkout/route.ts | 121 +++++++++++++++++++++--------- 2 files changed, 107 insertions(+), 38 deletions(-) diff --git a/src/app/admin/retouren/actions.ts b/src/app/admin/retouren/actions.ts index c361aa4..a818a2f 100644 --- a/src/app/admin/retouren/actions.ts +++ b/src/app/admin/retouren/actions.ts @@ -68,8 +68,6 @@ export async function refundReturnRequestAction(formData: FormData): Promise { - await tx.returnRequest.update({ where: { id }, data: { status: "ERSTATTET" } }); await tx.payment.update({ where: { orderId: returnRequest.orderId }, data: { status: "REFUNDED" } }); // Zurueckgesendete Ware wieder als verfuegbaren Bestand fuehren, analog // zur Stornierung im Checkout-Webhook. diff --git a/src/app/api/checkout/route.ts b/src/app/api/checkout/route.ts index 4fe5062..45fb5e6 100644 --- a/src/app/api/checkout/route.ts +++ b/src/app/api/checkout/route.ts @@ -1,6 +1,7 @@ import { NextResponse } from "next/server"; import { randomBytes } from "node:crypto"; import { prisma } from "@/lib/prisma"; +import { Prisma } from "@/generated/prisma/client"; import { getStripeClient } from "@/lib/stripe"; import { checkoutSchema } from "@/lib/validation"; import { getProductsByIds } from "@/lib/data"; @@ -14,6 +15,18 @@ import { getBaseUrl } from "@/lib/url"; import { getLocale } from "@/i18n/locale"; 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 { const year = new Date().getFullYear(); @@ -173,43 +186,71 @@ export async function POST(request: Request) { // updateMany (quantity >= bestellt) verhindert Ueberverkauf auch bei // gleichzeitigen Bestellungen — schlaegt eine Position fehl, wird die // gesamte Transaktion (inkl. bereits reservierter Positionen) verworfen. - order = await prisma.$transaction(async (tx) => { - for (const item of orderItemsData) { - const reserved = await tx.inventory.updateMany({ - where: { productId: item.productId, quantity: { gte: item.quantity } }, - data: { quantity: { decrement: item.quantity } }, - }); - if (reserved.count === 0) { - throw new OutOfStockError(item.productName); + order = await prisma.$transaction( + async (tx) => { + for (const item of orderItemsData) { + const reserved = await tx.inventory.updateMany({ + where: { productId: item.productId, quantity: { gte: item.quantity } }, + data: { quantity: { decrement: item.quantity } }, + }); + if (reserved.count === 0) { + throw new OutOfStockError(item.productName); + } } - } - return tx.order.create({ - 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, + // Verbindlicher Re-Check direkt vor dem Anlegen der Bestellung: der + // fruehe pendingUses-Check oben ist nur ein schneller Vorabfilter + // (bessere Fehlermeldung fuer den Normalfall), dieser hier — innerhalb + // derselben Serializable-Transaktion wie die Bestellanlage, mit einem + // frischen Datenbank-Stand statt der aeusseren (potenziell leicht + // veralteten) Momentaufnahme — ist der tatsaechlich verbindliche + // Schutz gegen zwei gleichzeitige Checkouts mit demselben knapp + // begrenzten Code. + if (discountCode) { + const freshCode = await tx.discountCode.findUnique({ where: { id: discountCode.id } }); + if (freshCode && freshCode.maxUses !== null) { + const pendingUses = await tx.order.count({ + where: { discountCodeId: freshCode.id, status: "NEU" }, + }); + if (freshCode.usedCount + pendingUses >= freshCode.maxUses) { + throw new DiscountCodeExhaustedError(); + } + } + } + + return tx.order.create({ + 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) { if (error instanceof OutOfStockError) { return NextResponse.json( @@ -219,6 +260,18 @@ export async function POST(request: Request) { { 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; }