Description
The claimRecommendation server action in src/app/actions/recommendations.ts enforces the 3-active-claim limit using a COUNT query followed by a separate UPDATE. Because these are two independent database round-trips, concurrent requests from the same user can both pass the count check before either write commits, allowing a user to hold more than 3 active claims simultaneously.
Affected File and Lines
src/app/actions/recommendations.ts -- claimRecommendation function
Buggy Code
// src/app/actions/recommendations.ts
export async function claimRecommendation(recId: number): Promise<Result<{ id: number }>> {
// Step 1: COUNT check (separate round-trip)
const { count: claimedCount } = await service
.from('recommendations')
.select('id', { count: 'exact', head: true })
.eq('user_id', user.id)
.eq('status', 'claimed');
if ((claimedCount ?? 0) >= 3) {
return err('claim_limit', 'you already have 3 active claims');
}
// Step 2: UPDATE (another round-trip -- race window between these two steps)
const { data, error: updateErr } = await service
.from('recommendations')
.update({ status: 'claimed', claimed_at: new Date().toISOString() })
.eq('id', recId)
.eq('user_id', user.id)
.eq('status', 'open')
.select('id')
.maybeSingle();
}
Race Condition Details
Between Step 1 and Step 2, another concurrent request for the same user can also pass the count check. Sending N simultaneous claim requests (trivially done via Promise.all from the browser or direct API calls) results in all N requests seeing the same count and all proceeding to the UPDATE. The user ends up holding more than 3 active claims.
The rate limiter (20 requests per 60s) does not prevent this -- all concurrent requests land within the same window and pass the rate check before any commit.
Impact
- Claim limit bypass: A user can hold an arbitrarily large number of active claims, monopolising issues and locking other contributors out of the recommendation pool.
- XP farming amplification: More active claims means more concurrent XP opportunities, multiplying the effectiveness of other exploits.
- Leaderboard integrity: The 3-claim cap is a core fairness constraint of the platform; bypassing it corrupts the ranked progression system.
Proposed Fix
Replace the two-step COUNT + UPDATE with a single atomic conditional UPDATE using a Supabase RPC backed by a PostgreSQL function:
-- migrations: create function claim_recommendation_atomic
CREATE OR REPLACE FUNCTION claim_recommendation_atomic(p_rec_id int, p_user_id uuid)
RETURNS TABLE(id int) LANGUAGE plpgsql AS $$
BEGIN
RETURN QUERY
UPDATE recommendations
SET status = 'claimed', claimed_at = now()
WHERE recommendations.id = p_rec_id
AND user_id = p_user_id
AND status = 'open'
AND (
SELECT count(*) FROM recommendations r2
WHERE r2.user_id = p_user_id AND r2.status = 'claimed'
) < 3
RETURNING recommendations.id;
END;
$$;
// recommendations.ts
const { data } = await service.rpc('claim_recommendation_atomic', {
p_rec_id: recId,
p_user_id: user.id,
});
if (!data?.length) return err('claim_limit_or_not_open', 'claim rejected');
This runs the check and the write as one atomic statement, eliminating the race window entirely.
Severity
High -- reproducible with a simple client-side Promise.all. Directly undermines platform fairness and leaderboard integrity.
I would like to work on this issue, contributing under NSoC'26. Please assign it to me.
Description
The
claimRecommendationserver action insrc/app/actions/recommendations.tsenforces the 3-active-claim limit using a COUNT query followed by a separate UPDATE. Because these are two independent database round-trips, concurrent requests from the same user can both pass the count check before either write commits, allowing a user to hold more than 3 active claims simultaneously.Affected File and Lines
src/app/actions/recommendations.ts--claimRecommendationfunctionBuggy Code
Race Condition Details
Between Step 1 and Step 2, another concurrent request for the same user can also pass the count check. Sending N simultaneous claim requests (trivially done via
Promise.allfrom the browser or direct API calls) results in all N requests seeing the same count and all proceeding to the UPDATE. The user ends up holding more than 3 active claims.The rate limiter (20 requests per 60s) does not prevent this -- all concurrent requests land within the same window and pass the rate check before any commit.
Impact
Proposed Fix
Replace the two-step COUNT + UPDATE with a single atomic conditional UPDATE using a Supabase RPC backed by a PostgreSQL function:
This runs the check and the write as one atomic statement, eliminating the race window entirely.
Severity
High -- reproducible with a simple client-side
Promise.all. Directly undermines platform fairness and leaderboard integrity.I would like to work on this issue, contributing under NSoC'26. Please assign it to me.