Skip to content

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

@anshul23102

Description

@anshul23102

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.

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

No type
No fields configured for issues without a type.

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions