Skip to content
This repository was archived by the owner on Jul 11, 2026. It is now read-only.
This repository was archived by the owner on Jul 11, 2026. It is now read-only.

feat: reward store redemptions have no server-side validation — members can redeem rewards with insufficient points via direct POST requests, bypassing the client-side balance check #260

Description

@divyanshim27

Summary

The Gamify reward store allows members to redeem points for real or
virtual rewards. The frontend displays available balance and disables
the redeem button when points are insufficient. However, this is
purely client-side enforcement.

The backend route POST /api/rewards/redeem (in server/routes/) does
not verify that user.points >= reward.cost before processing the
redemption. A member with 50 points can redeem a 500-point reward by
sending a raw HTTP request directly to the API.

Reproduction

# Member with 50 points redeems a 500-point reward by bypassing the UI:
curl -X POST http://localhost:5000/api/rewards/redeem \
  -H "Authorization: Bearer <valid_jwt>" \
  -H "Content-Type: application/json" \
  -d '{"rewardId": "<500-point-reward-id>"}'

# Result: Redemption succeeds. User balance goes negative.
# MongoDB User document: { points: -450 }

This is a critical integrity flaw in a gamification platform —
the entire value proposition depends on points being a scarce,
verifiable resource.

Proposed Fix

Add balance validation in server/controllers/rewardController.js:

// rewardController.js — redeem handler
export const redeemReward = async (req, res) => {
  const { rewardId } = req.body;
  const userId = req.user.id; // from authMiddleware

  const [user, reward] = await Promise.all([
    User.findById(userId),
    Reward.findById(rewardId),
  ]);

  if (!reward) {
    return res.status(404).json({ success: false, message: 'Reward not found' });
  }

  // ✅ Server-side balance check — cannot be bypassed by client
  if (user.points < reward.cost) {
    return res.status(400).json({
      success: false,
      message: `Insufficient points. Required: ${reward.cost}, Available: ${user.points}`,
    });
  }

  // Atomic deduction — prevents race condition on concurrent redemptions
  const updatedUser = await User.findOneAndUpdate(
    { _id: userId, points: { $gte: reward.cost } }, // atomic check
    { $inc: { points: -reward.cost } },
    { new: true }
  );

  if (!updatedUser) {
    return res.status(400).json({
      success: false,
      message: 'Redemption failed — concurrent balance change detected',
    });
  }

  // Record redemption...
};

Note: The $gte condition in findOneAndUpdate makes the balance check
atomic — preventing a race condition where two simultaneous redemptions
both pass the check before either deducts points.

Acceptance Criteria

  • rewardController.js validates user.points >= reward.cost
    server-side before any redemption
  • Atomic findOneAndUpdate with $gte guard prevents race conditions
  • API returns structured error with required and available fields
  • Unit test: attempt redemption with insufficient points → 400 response

Labels: bug, security, backend, level: intermediate

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions