Skip to content

[Bug] awardRecommendedMerge uses rec.xp_reward from the database without capping to the difficulty-tier maximum, enabling XP inflation #216

@anshul23102

Description

@anshul23102

Summary

In src/inngest/functions/process-pr-event.ts, the awardRecommendedMerge function passes rec.xp_reward directly from the database into insertXpEvent as the xpDelta. There is no validation against the defined tier caps in XP_REWARDS.RECOMMENDED_MERGE.

Affected File

src/inngest/functions/process-pr-event.ts

Root Cause

The insert call reads:

const inserted = await insertXpEvent({
  userId: rec.user_id,
  source: XP_SOURCE.RECOMMENDED_MERGE,
  refType: 'pr',
  refId: refIds.pr(repo, pr.number),
  repo,
  difficulty,
  xpDelta: rec.xp_reward ?? xpForMerge(difficulty),
});

rec.xp_reward comes directly from a database row. The constants defined in src/lib/xp/sources.ts are:

export const XP_REWARDS = {
  RECOMMENDED_MERGE: { E: 50, M: 150, H: 400 },
  // ...
} as const;

These caps are never applied when rec.xp_reward is non-null. Any row in the recommendations table with an inflated xp_reward value (via direct database access, a compromised migration, or a future bug in a write path) will award that uncapped amount without any application-layer check.

Impact

  • A rec.xp_reward of 999999 for an Easy PR would be awarded as-is; the defined cap of 50 is silently bypassed.
  • The idempotency guard (UNIQUE on user_id, source, ref_id) prevents double-awarding the same PR, but it does not prevent a single inflated award from being committed.
  • Leaderboard rankings and any downstream XP-gated features are directly affected.

Steps to Reproduce

  1. Insert a recommendations row with xp_reward = 9999 and difficulty E.
  2. Merge a PR that matches the recommendation.
  3. Observe the awarded XP delta is 9999 rather than the expected cap of 50.

Expected Behaviour

xpDelta should be clamped to the tier ceiling before being persisted:

const tierCap = XP_REWARDS.RECOMMENDED_MERGE[difficulty as keyof typeof XP_REWARDS.RECOMMENDED_MERGE]
  ?? xpForMerge(difficulty);
const xpDelta = Math.min(rec.xp_reward ?? tierCap, tierCap);

Severity

High - This is a data-integrity issue in the reward pipeline. An uncapped write can corrupt leaderboard state and cannot be cleanly reversed once insertXpEvent succeeds (the idempotency key blocks a corrective re-award at the same refId).

Metadata

Metadata

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