fix: close TOCTOU race condition in claimRecommendation#211
Closed
anshul23102 wants to merge 1 commit into
Closed
Conversation
Contributor
|
@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.
3059e1d to
7c4e92e
Compare
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. |
Author
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #205
Root Cause
claimRecommendationenforced the 3-active-claim cap with two sequential database operations:SELECT COUNT(*)to check the current claimed countUPDATE ... SET status='claimed' WHERE status='open'to write the claimBecause 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.allreliably 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.sqlAdds 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 singleUPDATEstatement: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 DEFINERwithSET search_path = publicto prevent search-path injection, andGRANT EXECUTEis given to bothauthenticatedandservice_role.src/app/actions/recommendations.tsReplaced the COUNT query and the subsequent
maybeSingleUPDATE with a singleservice.rpc('claim_recommendation_atomic', ...)call. Zero rows returned is interpreted as a unifiedclaim_limit_or_not_openerror (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