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
- User B (attacker) claims any issue on MergeShip and calls
linkPrToRec with User A's (victim's) real open-source PR URL.
- When User A's PR is merged on GitHub, the
pull_request.closed webhook fires.
handleMerge queries recommendations by linked_pr_url and finds User B's row.
- Full XP is awarded to User B for a PR they never wrote.
- 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.
Description
The
linkPrToRecserver action insrc/app/actions/recommendations.tsvalidates 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--linkPrToRecfunctionBuggy Code
And in
src/inngest/functions/process-pr-event.ts, the merge handler awards XP to whoever owns the matchinglinked_pr_urlrow:Exploit Scenario
linkPrToRecwith User A's (victim's) real open-source PR URL.pull_request.closedwebhook fires.handleMergequeriesrecommendationsbylinked_pr_urland finds User B's row.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.loginmatches the caller's GitHub handle before writinglinked_pr_url: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.