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
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.
Overview
MilestonesService.addIssuereassigns an issue'smilestoneIdwith no check that the issue actually belongs to the milestone's own repository:Both
MilestoneandIssuecarry arepositoryId(milestone.entity.ts:27-28,issue.entity.ts:26-27) — a milestone is explicitly scoped to one repository (MilestonesService.createrequiresrepositoryIdfrom the DTO,milestones.service.ts:22-34) and its whole purpose, per its own doc comment inresolveIssue, is to "distribute this milestone's budget proportionally as its issues resolve" for that repository's work.addIssuenever comparesissue.repositoryIdtomilestone.repositoryIdbefore 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: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
addIssuetime. A sponsor funds a milestone formy-org/repo-A's maintenance work; if any issue fromunrelated-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 ofrepo-A's dedicated budget the moment someone resolves it, andrepo-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
addIssuenever checking the issue and milestone belong to the same repository at all, a plain missing-validation bug, independent of concurrency or reassignment history.Requirements
addIssue: reject (BadRequestException) ifissue.repositoryId !== milestone.repositoryId.Milestone.repositoryIdis required at creation butIssue.repositoryIdcould, 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.MilestonesController.addIssueneeds the same authorization treatment as the rest of the money-moving surface (see the companion "no auth at all" issue) — it's exposed asPOST /milestones/:id/issues/:issueIdwith 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
addIssuerejects attaching an issue whoserepositoryIddoesn't match the target milestone'srepositoryId.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 theaddIssuetest, carried through aresolveIssuecall).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 unguardedPOST /milestones/:id/issues/:issueIdroute).Test/reproduction plan:
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.