Skip to content

fix: close TOCTOU race condition in claimRecommendation#211

Closed
anshul23102 wants to merge 1 commit into
Coder-s-OG-s:mainfrom
anshul23102:fix/205-toctou-claim-limit
Closed

fix: close TOCTOU race condition in claimRecommendation#211
anshul23102 wants to merge 1 commit into
Coder-s-OG-s:mainfrom
anshul23102:fix/205-toctou-claim-limit

Conversation

@anshul23102
Copy link
Copy Markdown

Fixes #205

Root Cause

claimRecommendation enforced the 3-active-claim cap with two sequential database operations:

  1. SELECT COUNT(*) to check the current claimed count
  2. UPDATE ... SET status='claimed' WHERE status='open' to write the claim

Because these are independent round-trips, a second concurrent request for the same user can execute its own COUNT query between step 1 and step 2 of the first request. Both requests see a count below 3, both pass the guard, and both commit their updates. Sending N simultaneous requests via Promise.all reliably reproduces this -- the user ends up with N active claims instead of at most 3. The existing rate limit (20 req/60s) does not prevent this because all concurrent requests land in the same window before any commit.

Fix

supabase/migrations/0012_claim_recommendation_atomic.sql

Adds a new Postgres function claim_recommendation_atomic(p_rec_id bigint, p_user_id uuid) that merges the count check and the write into a single UPDATE statement:

UPDATE recommendations AS r
SET    status = 'claimed', claimed_at = now()
WHERE  r.id      = p_rec_id
  AND  r.user_id = p_user_id
  AND  r.status  = 'open'
  AND  (
    SELECT COUNT(*) FROM recommendations r2
    WHERE  r2.user_id = p_user_id AND r2.status = 'claimed'
  ) < 3
RETURNING r.id;

The subquery that evaluates the count and the row that gets written are handled atomically by the database engine. Concurrent calls are serialised at the row level -- only one can succeed when the count is at the limit.

The function is SECURITY DEFINER with SET search_path = public to prevent search-path injection, and GRANT EXECUTE is given to both authenticated and service_role.

src/app/actions/recommendations.ts

Replaced the COUNT query and the subsequent maybeSingle UPDATE with a single service.rpc('claim_recommendation_atomic', ...) call. Zero rows returned is interpreted as a unified claim_limit_or_not_open error (covers both "limit reached" and "rec not open" cases). The UI re-fetches state on any claim error, so distinguishing the two rejection reasons is not needed client-side.

How to Test

// Reproduce the race before this fix (run in a browser console or test):
await Promise.all([
  claimRecommendation(recId1),
  claimRecommendation(recId2),
  claimRecommendation(recId3),
  claimRecommendation(recId4), // would succeed before fix
]);
// After fix: exactly 3 succeed, the 4th returns claim_limit_or_not_open.

// Verify normal single-claim flow still works:
const result = await claimRecommendation(openRecId);
// result.ok === true, result.value.id === openRecId

@vercel
Copy link
Copy Markdown
Contributor

vercel Bot commented May 25, 2026

@anshul23102 is attempting to deploy a commit to the codersogs-3057's projects Team on Vercel.

A member of the Team first needs to authorize it.

Fixes Coder-s-OG-s#205

The previous implementation enforced the 3-claim cap with two independent
database round-trips: a COUNT query, then a separate UPDATE. Between those
two steps, a second concurrent request for the same user could also read
count < 3, pass the guard, and commit its own UPDATE. Sending multiple
claim requests in parallel (trivially via Promise.all) reproduced the bug
reliably -- the user ended up with more than 3 active claims.

Fix: replaced the two-step sequence with a call to a new database function,
claim_recommendation_atomic, added in migration 0012. The function merges
the count check and the write into a single UPDATE statement. The subquery
that evaluates the claim count and the row that gets written are handled
atomically by the database engine, so concurrent calls are serialised at
the row level and only one can succeed when the count is at the limit.

Migration 0012 (supabase/migrations/0012_claim_recommendation_atomic.sql):
  - Defines claim_recommendation_atomic(p_rec_id bigint, p_user_id uuid)
    returning TABLE(id bigint).
  - The UPDATE only proceeds if the caller's current claimed count is
    strictly less than 3 AND the target rec is still open. Zero rows
    returned covers both rejection cases (limit reached or rec not open).
  - SECURITY DEFINER with SET search_path = public prevents search-path
    injection.
  - GRANT EXECUTE given to both authenticated and service_role so server
    actions and background functions can call it without privilege changes.

recommendations.ts:
  - Removed the COUNT query and the subsequent maybeSingle UPDATE.
  - Added service.rpc('claim_recommendation_atomic', ...) in their place.
  - Interprets zero returned rows as a unified claim_limit_or_not_open
    error; the UI already re-fetches state on any error so the distinction
    between the two rejection reasons is not needed client-side.
  - data.id reference updated to claimedId extracted from the RPC result.
@anshul23102 anshul23102 force-pushed the fix/205-toctou-claim-limit branch from 3059e1d to 7c4e92e Compare May 25, 2026 01:59
@anshul23102
Copy link
Copy Markdown
Author

This PR is ready for review. Both changed files have been tested against the reproduction steps in the issue description. Happy to make any adjustments based on your feedback.

@anshul23102
Copy link
Copy Markdown
Author

Closing in favour of #224, which supersedes this with a cleaner implementation, proper unit tests, and a correct error-code distinction between claim_limit and already_claimed. All changes from this PR are included in #224.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Bug: TOCTOU race condition in claimRecommendation allows bypassing the 3-active-claim limit

1 participant