fix: close remaining TOCTOU races, self-correct a regression from the prior fix

Second silent-failure-hunter pass (stock/auth/admin-action focus,
explicitly scoped away from the payment paths fixed in the previous
commit) plus a self-review of that previous commit's own diff.

Self-correction: my prior fix to fulfillOrder's NEU->BEZAHLT guard
replaced the original atomic `updateMany({status:"NEU"})` claim with a
plain findUnique+update to let a retry backfill Payment/Invoice/email
after BEZAHLT. That accidentally dropped the atomicity the original
code relied on to prevent double-counting a discount code on two
concurrent webhook deliveries. Restored it: the NEU->BEZAHLT
transition is an atomic updateMany claim again (count===1 decides
"firstTime", which now also gates the discount-code increment); a
losing/retry call separately checks for "already BEZAHLT" to still
allow the backfill.

Three further real gaps, confirmed by hand before fixing:

- failOrder (same webhook file) and admin's updateOrderStatusAction
  had the identical TOCTOU pattern: findUnique then unconditional
  update then restockOrderItems. Two concurrent webhook deliveries
  (Stripe documents at-least-once delivery) or two admin tabs
  double-clicking "Stornieren" could both pass the stale read before
  either write landed, silently double-crediting inventory. Both now
  use the same atomic-claim pattern as fulfillOrder.

- 2FA backup-code verification used findFirst({usedAt:null}) then a
  separate update — two concurrent submissions of the same code could
  both pass the check and both authenticate, defeating single-use.
  Replaced with one atomic updateMany claim.

- Two silent no-ops: an invalid/stale order-status update returned
  with zero admin-visible feedback despite revalidatePath still
  firing; discount-code deletion had no existence check, so a
  double-submitted delete threw an unhandled Prisma P2025 (500)
  instead of a clear message. Both now use the existing fail() helper.

Verified: tsc clean, 151/151 existing tests still pass.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
This commit is contained in:
Nick SzaunigandClaude Sonnet 5 committed 2026-09-08 21:05:05 +02:00
1 parent 8b5d5d095c
commit 3627f4e5c1
4 files changed
+81 -34

No files matched your search

+11 -5
View File
@@ -123,13 +123,19 @@ export async function verifyTwoFactorAction(formData: FormData): Promise<void> {
if (!authenticated && code.length > 0) {
const codeHash = hashBackupCode(code.toUpperCase());
const backupCode = await prisma.twoFactorBackupCode.findFirst({
// Atomarer Claim statt findFirst+update: ein getrenntes Lesen und
// Schreiben liesse zwei parallele Anfragen mit demselben (z.B.
// abgefangenen) Backup-Code beide noch den "unbenutzt"-Stand sehen,
// bevor eine von ihnen schreibt — beide wuerden dann authentifiziert
// und die Einmal-Verwendbarkeit des Codes waere ausgehebelt. Die
// WHERE-Klausel im UPDATE selbst wird dagegen atomar gegen den
// aktuellen Zeilenstand ausgewertet, sodass hoechstens ein Aufruf
// "gewinnt" (count === 1).
const claim = await prisma.twoFactorBackupCode.updateMany({
where: { customerId: customer.id, codeHash, usedAt: null },
data: { usedAt: new Date() },
});
if (backupCode) {
await prisma.twoFactorBackupCode.update({ where: { id: backupCode.id }, data: { usedAt: new Date() } });
authenticated = true;
}
authenticated = claim.count === 1;
}
if (!authenticated) {