Skip to content

Security: linkPrToRec does not verify PR ownership, enabling XP theft by linking any merged PR #203

@anshul23102

Description

@anshul23102

Description

The linkPrToRec server action in src/app/actions/recommendations.ts validates only the URL format of a submitted PR link. It does not verify that the authenticated user actually authored the linked pull request. This allows any user to claim XP for a PR written by someone else.

Affected File and Lines

src/app/actions/recommendations.ts -- linkPrToRec function

Buggy Code

// src/app/actions/recommendations.ts
const PR_URL_RE = /^https:\/\/github\.com\/[^/]+\/[^/]+\/pull\/\d+$/;

export async function linkPrToRec(recId: number, prUrl: string): Promise<Result<{ id: number }>> {
  // ...auth checks...

  const trimmed = prUrl.trim();
  if (!PR_URL_RE.test(trimmed)) {
    return err('invalid_url', 'paste a full https://github.com/<owner>/<repo>/pull/<n> URL');
  }

  // Only validates format -- no check that the PR was opened by the caller.
  const { data, error: updateErr } = await service
    .from('recommendations')
    .update({ linked_pr_url: trimmed })
    .eq('id', recId)
    .eq('user_id', user.id)
    .in('status', ['open', 'claimed'])
    .select('id')
    .maybeSingle();
  // ...
}

And in src/inngest/functions/process-pr-event.ts, the merge handler awards XP to whoever owns the matching linked_pr_url row:

// process-pr-event.ts -- handleMerge()
const { data: rec } = await sb
  .from('recommendations')
  .select('id, user_id, difficulty, xp_reward, status')
  .eq('linked_pr_url', prUrl)   // matches by URL alone, not by PR author
  .maybeSingle();

if (rec) {
  return await awardRecommendedMerge(sb, rec, repo, pr);  // XP goes to rec owner
}

Exploit Scenario

  1. User B (attacker) claims any issue on MergeShip and calls linkPrToRec with User A's (victim's) real open-source PR URL.
  2. When User A's PR is merged on GitHub, the pull_request.closed webhook fires.
  3. handleMerge queries recommendations by linked_pr_url and finds User B's row.
  4. Full XP is awarded to User B for a PR they never wrote.
  5. User A receives nothing (no matching rec row, falls through to the 5 XP unrecommended-merge path).

Because the PR URL is public on any open-source repo, any user can steal credit for the highest-XP merged PRs (Hard difficulty, 200+ XP) with a single API call.

Proposed Fix

After validating the URL format, call the GitHub API to fetch the PR and confirm pr.user.login matches the caller's GitHub handle before writing linked_pr_url:

// After format validation, before the DB update:
const sessionRes = await sb.auth.getSession();
const token = sessionRes.data.session?.provider_token;
if (token) {
  const match = trimmed.match(
    /^https:\/\/github\.com\/([^/]+)\/([^/]+)\/pull\/(\d+)$/
  );
  if (match) {
    const [, owner, repo, number] = match;
    const ghRes = await fetch(
      `https://api.github.com/repos/${owner}/${repo}/pulls/${number}`,
      { headers: { Authorization: `Bearer ${token}` } }
    );
    if (ghRes.ok) {
      const prData = await ghRes.json();
      const { data: profile } = await service
        .from('profiles')
        .select('github_handle')
        .eq('id', user.id)
        .single();
      if (prData.user?.login?.toLowerCase() !== profile?.github_handle?.toLowerCase()) {
        return err('not_your_pr', 'you can only link PRs you authored');
      }
    }
  }
}

Severity

Critical -- directly exploitable to farm arbitrary XP, corrupts the leaderboard, and unfairly removes XP credit from the real PR author. Requires no special privileges -- any authenticated MergeShip user can exploit this.

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