Skip to content

MilestonesService.addIssue never validates that the issue belongs to the milestone's own repository, allowing cross-repository issue attachment and budget misallocation #59

Description

@chonilius

Overview

MilestonesService.addIssue reassigns an issue's milestoneId with no check that the issue actually belongs to the milestone's own repository:

// src/milestones/milestones.service.ts:68-75
/** Attaches an already-tracked issue to this milestone. */
async addIssue(milestoneId: string, issueId: string): Promise<Issue> {
  const milestone = await this.findOne(milestoneId);
  const issue = await this.issueRepo.findOne({ where: { id: issueId } });
  if (!issue) throw new NotFoundException(`Issue ${issueId} not found`);
  issue.milestoneId = milestone.id;
  return this.issueRepo.save(issue);
}

Both Milestone and Issue carry a repositoryId (milestone.entity.ts:27-28, issue.entity.ts:26-27) — a milestone is explicitly scoped to one repository (MilestonesService.create requires repositoryId from the DTO, milestones.service.ts:22-34) and its whole purpose, per its own doc comment in resolveIssue, is to "distribute this milestone's budget proportionally as its issues resolve" for that repository's work. addIssue never compares issue.repositoryId to milestone.repositoryId before attaching — any tracked issue from any repository this MergeFi instance syncs can be attached to any milestone, regardless of which repo it actually belongs to.

Trace the consequence through resolveIssue's budget math:

// src/milestones/milestones.service.ts:102-107
const openIssues = milestone.issues.filter((i) => i.state === 'open');
const unresolvedCount = Math.max(openIssues.length, 1);
const remainingBudget = Number(milestone.budget) - Number(milestone.distributed);
const share = Math.min(remainingBudget / unresolvedCount, remainingBudget);

The budget is split evenly across whatever's currently attached, full stop — there's no per-issue repository check here either, and there doesn't need to be, because the bug already happened at addIssue time. A sponsor funds a milestone for my-org/repo-A's maintenance work; if any issue from unrelated-org/repo-B (or, more mundanely, a different repository the same sponsor also happens to maintain) gets attached — via a copy-pasted issue ID, a UI bug on the client side that doesn't filter the issue picker by repository, or simply a maintainer fat-fingering the wrong UUID — that unrelated repo's issue now silently competes for a share of repo-A's dedicated budget the moment someone resolves it, and repo-A's own issues each get a smaller share than the sponsor intended, with nothing in the API surfacing that the milestone's issue set has drifted outside its own repository.

This is a distinct gap from the already-open "Milestone addIssue and resolveIssue have no protection against an issue being attached to (or resolved under) two milestones simultaneously" issue in this repo — that issue is about the same-issue-two-milestones concurrency/reassignment race; this issue is about addIssue never checking the issue and milestone belong to the same repository at all, a plain missing-validation bug, independent of concurrency or reassignment history.

Requirements

  • Add a check in addIssue: reject (BadRequestException) if issue.repositoryId !== milestone.repositoryId.
  • Since Milestone.repositoryId is required at creation but Issue.repositoryId could, in principle, differ from what a caller thinks it is (a stale client-side cache, a UUID typo), make the resulting error message include both repository IDs so a caller can immediately see the mismatch rather than getting a generic rejection.
  • Add a test: create two milestones for two different repositories, attempt to attach repo-B's issue to repo-A's milestone, assert rejection; confirm attaching a same-repository issue continues to work.
  • While auditing this method, check whether MilestonesController.addIssue needs the same authorization treatment as the rest of the money-moving surface (see the companion "no auth at all" issue) — it's exposed as POST /milestones/:id/issues/:issueId with no guard today, meaning even with the repository check added, anyone can currently attach any of their own repo's issues to someone else's milestone, redirecting that sponsor's budget toward work the sponsor never agreed to fund; that's this batch's general auth gap applying here specifically, worth a one-line acknowledgment in the fix even though the primary ask is the repository-match validation.

Acceptance Criteria

  • addIssue rejects attaching an issue whose repositoryId doesn't match the target milestone's repositoryId.
  • The rejection is a clear, actionable error identifying both repository IDs involved.
  • A test proves cross-repository attachment is rejected and same-repository attachment continues to work.
  • resolveIssue's budget-sharing math is confirmed, via test, to never include an issue from a different repository than the milestone once this fix lands (regression test using the same two-milestone/two-repository setup as the addIssue test, carried through a resolveIssue call).

Additional Notes

Precise references: src/milestones/milestones.service.ts:68-75 (addIssue, the bug), :82-129 (resolveIssue, where the consequence surfaces), src/common/entities/milestone.entity.ts:23-28 (Milestone.repositoryId, required, non-nullable), src/common/entities/issue.entity.ts:22-27 (Issue.repositoryId), src/milestones/milestones.controller.ts:48-51 (the unguarded POST /milestones/:id/issues/:issueId route).

Test/reproduction plan:

const repoA = await repositoryRepo.save({ owner: 'org', name: 'repo-a', githubRepoId: '1', ... });
const repoB = await repositoryRepo.save({ owner: 'org', name: 'repo-b', githubRepoId: '2', ... });
const milestoneA = await milestonesService.create({ repositoryId: repoA.id, title: 'Q1 maintenance', budget: '100.0000000', asset: AssetType.USDC });
const issueB = await issueRepo.save({ repositoryId: repoB.id, number: 1, title: 'unrelated', githubIssueId: 'gh-1', githubUrl: 'https://...' });

await expect(milestonesService.addIssue(milestoneA.id, issueB.id)).rejects.toThrow(BadRequestException);
// pre-fix: resolves successfully, issueB.milestoneId = milestoneA.id

Cross-references: distinct from, but adjacent to, the existing open "Milestone addIssue and resolveIssue have no protection against an issue being attached to (or resolved under) two milestones simultaneously" issue — that one covers reassignment-away-from-a-funded-milestone and concurrent-resolution races; this one covers the simpler, unconditional cross-repository attachment gap that exists regardless of concurrency or prior milestone history. Both touch the same method and should probably be reviewed together to avoid merge conflicts, but are independent bugs with independent fixes.

Metadata

Metadata

Labels

GrantFox OSSIssue tracked in GrantFox OSSMaybe RewardedIssue may be eligible for a GrantFox rewardOfficial Campaign | FWC26Campaign: Official Campaign | FWC26Stellar WaveIssues in the Stellar wave programThird CampaignCampaign: Third CampaignbugSomething isn't workingvery hardVery difficult task, expert-level effort required

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions