fix(invites): close single-use invite max_uses TOCTOU (H1)
Redeeming an invite read uses_count, checked the cap in JS, then did a non-atomic uses_count = uses_count + 1 write AFTER creating the membership. Two concurrent redemptions of a max_uses=1 invite both passed the check and both created a member, and the counter under-counted. Fix: claim a use ATOMICALLY before creating the membership via a compare-and-swap on uses_count (lib/invite-claim.ts) — the UPDATE only writes when uses_count still equals the value the cap decision was based on, so of two racing claims exactly one wins. The loser re-reads and retries while capacity remains (multi-use invites unaffected). On a failed membership insert the claim is rolled back so a slot is not burned. Extracted to lib/invite-claim.ts (out of the 'use server' surface, unit-tested). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
624a708d8c
commit
2ee929658e
@@ -0,0 +1,47 @@
|
||||
import type { createAdminClient } from '@/lib/supabase/admin'
|
||||
|
||||
type Admin = ReturnType<typeof createAdminClient>
|
||||
|
||||
/**
|
||||
* Atomically claim one use of a multi-code invite via compare-and-swap on
|
||||
* `uses_count`, closing the check-then-increment TOCTOU where two concurrent
|
||||
* redemptions of a single-use invite could both succeed.
|
||||
*
|
||||
* The UPDATE only writes when `uses_count` still equals the value we based our
|
||||
* cap decision on, so of two racing claims exactly one wins; the loser sees a
|
||||
* 0-row result, re-reads, and retries while capacity remains. `uses_count` only
|
||||
* ever increases, so the CAS has no ABA hazard.
|
||||
*
|
||||
* Returns the new `uses_count` on success (the caller stores it to roll the
|
||||
* claim back if the subsequent membership insert fails), or `null` if the cap
|
||||
* is reached.
|
||||
*/
|
||||
export async function claimInviteUse(
|
||||
admin: Admin,
|
||||
inviteId: string,
|
||||
knownCount: number,
|
||||
maxUses: number | null,
|
||||
maxAttempts = 5,
|
||||
): Promise<number | null> {
|
||||
let current = knownCount
|
||||
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
||||
if (maxUses !== null && current >= maxUses) return null
|
||||
const next = current + 1
|
||||
const { data } = await admin
|
||||
.from('space_invites')
|
||||
.update({ uses_count: next })
|
||||
.eq('id', inviteId)
|
||||
.eq('uses_count', current) // compare-and-swap guard
|
||||
.select('id')
|
||||
if (data && data.length > 0) return next
|
||||
// Lost the race (or the row changed): re-read and retry while under cap.
|
||||
const { data: fresh } = await admin
|
||||
.from('space_invites')
|
||||
.select('uses_count')
|
||||
.eq('id', inviteId)
|
||||
.maybeSingle()
|
||||
if (!fresh) return null
|
||||
current = (fresh as { uses_count: number }).uses_count
|
||||
}
|
||||
return null
|
||||
}
|
||||
Reference in New Issue
Block a user