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:
1 parent
8b5d5d095c
commit
3627f4e5c1
4 files changed
+81
-34
No files matched your search
@@ -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) {
|
||||
|
||||
Reference in new issue
Block a user