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
Labels: bug, security, backend, level: intermediate
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(inserver/routes/) doesnot verify that
user.points >= reward.costbefore processing theredemption. A member with 50 points can redeem a 500-point reward by
sending a raw HTTP request directly to the API.
Reproduction
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:Note: The
$gtecondition infindOneAndUpdatemakes the balance checkatomic — preventing a race condition where two simultaneous redemptions
both pass the check before either deducts points.
Acceptance Criteria
rewardController.jsvalidatesuser.points >= reward.costserver-side before any redemption
findOneAndUpdatewith$gteguard prevents race conditionsrequiredandavailablefieldsLabels:
bug,security,backend,level: intermediate