MilestonesService.close ignores its userId argument — any SPONSOR or MAINTAINER can close and refund another sponsor's funded milestone
メンテナーはふだん 1 日以内に返信
まだ誰も着手していません。
評価
- 難易度
- 3/5
- 見積もり時間
- 1〜2日
- 初心者へのやさしさ
- 75/100
- issue の種類
- バグ
- 明瞭さ
- 明確に書かれている
- 活発さ
- 活発
- 技術スタック
- node.js, typescript
調査の方向性
Start with src/milestones/milestones.service.ts, especially close() and findOne(), then compare authorization in fund() and src/bounties/bounties.service.ts. Check how the milestone repository relation exposes its maintainer and review src/milestones/milestones.service.spec.ts for test setup. Done when unauthorized callers receive 403 without a refund, permitted callers can close, and the unreachable guard is removed.
索引モデルが issue の本文から書いたものです。
説明
Location
src/milestones/milestones.service.ts:262 — MilestonesService.close(id, userId)
Problem
The controller correctly extracts the authenticated caller and passes it in (src/milestones/milestones.controller.ts:155-161):
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles(UserRole.SPONSOR, UserRole.MAINTAINER)
@Post(':id/close')
close(@Param('id', new ParseUUIDPipe()) id: string, @Req() req: Request) {
const userId = (req.user as any)?.userId;
return this.milestonesService.close(id, userId);
}
But the service never uses the parameter:
async close(id: string, userId: string): Promise<Milestone> {
const milestone = await this.findOne(id);
if (!milestone) { throw new NotFoundException(`Milestone ${id} not found`); }
assertTransition(milestone.status, MilestoneStatus.CLOSED);
if (milestone.escrowId) { ... await this.escrowService.refund(milestone.escrowId); ... }
milestone.status = MilestoneStatus.CLOSED;
return this.milestoneRepo.save(milestone);
}
userId is dead. The milestone is bound entirely from the URL :id, so authorization stops at "the caller holds the SPONSOR or MAINTAINER role somewhere on the platform".
The sibling method in the same file does the binding properly — MilestonesService.fund (line 73):
if (milestone.sponsorId && milestone.sponsorId !== callerUserId) {
throw new ForbiddenException('Only the milestone sponsor can fund this milestone');
}
as does BountiesService.refund (src/bounties/bounties.service.ts:257-265) for the equivalent bounty operation. close() is the one money-moving milestone route with no such check.
Two further dead-code smells in the same method confirm it was never finished: the if (!milestone) check on line 264 is unreachable because findOne (line 55-62) already throws NotFoundException, and the catch (err) on line 273 binds err and does nothing with it.
Why it matters
close() triggers an on-chain refund of the escrow to the original funder and then drives the milestone to the terminal CLOSED status (MILESTONE_TRANSITIONS[CLOSED] = [], src/milestones/milestone-state-machine.ts:21). There is no reopen path.
So any account holding SPONSOR or MAINTAINER can, for any milestone belonging to anyone:
- force an on-chain refund of its escrow, and
- permanently terminate it — contributors mid-work on its attached issues can no longer be paid, because
resolveIssuerequires a transition toIN_PROGRESS, whichCLOSEDforbids.
This is not fund theft (the refund goes to the original funder, not the caller) but it is an unauthenticated-by-ownership denial of service against other sponsors' funded work, and it is irreversible. Combined with the IN_PROGRESS -> CLOSED transition being legal, it can be fired mid-milestone.
Worth noting that SPONSOR/MAINTAINER are platform-wide roles, not per-repository ones, so the role gate narrows the attacker set only to "any account that has ever been granted either role".
Reproduction / failure path
- Sponsor A:
POST /milestoneswith{ "repositoryId": "R", "budget": "1000.0000000", "asset": "USDC" }→ milestoneMwithsponsorId = A. - Sponsor A:
POST /milestones/M/fundwith{ "funderAddress": "G...A" }→ escrowElocked,M.status = FUNDED. - Sponsor B (unrelated account, holds
SPONSOR, no connection toM,R, orE), using their own JWT:
POST /milestones/M/close
(no body)
- Expected:
403 Forbidden. Actual:200 OK.Eis refunded on-chain andM.status = CLOSED, permanently.
Suggested fix
Use the userId the controller already supplies, and reject a caller who is neither the milestone's sponsor nor the maintainer of its repository:
async close(id: string, callerUserId: string): Promise<Milestone> {
const milestone = await this.milestoneRepo.findOne({
where: { id },
relations: { repository: true },
});
if (!milestone) throw new NotFoundException(`Milestone ${id} not found`);
const isSponsor = milestone.sponsorId === callerUserId;
const isRepoMaintainer =
!!milestone.repository?.maintainerId &&
milestone.repository.maintainerId === callerUserId;
if (!isSponsor && !isRepoMaintainer) {
throw new ForbiddenException(
"Only the milestone sponsor or the repository's maintainer can close this milestone",
);
}
...
}
Note findOne currently loads relations: { issues: true } only, so repository needs to be loaded for the maintainer branch.
Also remove the unreachable if (!milestone) guard on line 264, and add service specs for close() — the method has no test coverage at all in src/milestones/milestones.service.spec.ts — including a case asserting an unrelated SPONSOR gets 403 and the escrow is not refunded.
- 主要言語
- TypeScript
- スター
- 2
- フォーク
- 90
- 平均マージ
- 31分
- マージ済み PR(30日)
- 20
環境構築
- Dockerfile または Docker Compose ファイルあり
- プルリクエストのテンプレートなし
- コントリビューションガイドなし
はじめの一歩
- issue を最後まで読み、次にプロジェクトのコントリビューションガイドを読みます。
- 着手することを issue にコメントします — 二人が同じ作業をするのを防げます。
- リポジトリをフォークし、ブランチを切って変更します。
- issue 番号を参照したプルリクエストを送ります。
MergeFi/backend のほかの issue
-
bug
難易度 2/5 1〜3時間 初心者へのやさしさ 85/100
メンテナーはふだん 1 日以内に返信
-
bug security
難易度 2/5 1〜3時間 初心者へのやさしさ 74/100
メンテナーはふだん 1 日以内に返信
-
bug
難易度 2/5 1〜3時間 初心者へのやさしさ 82/100
メンテナーはふだん 1 日以内に返信
-
bug testing
難易度 2/5 1〜3時間 初心者へのやさしさ 85/100
メンテナーはふだん 1 日以内に返信
-
bug security
難易度 2/5 1〜3時間 初心者へのやさしさ 76/100
メンテナーはふだん 1 日以内に返信
MergeFi/backend の issue をすべて見る
似ている issue
-
Codex使用可 コード
難易度 2/5 1〜3時間 初心者へのやさしさ 72/100
ryuki-moro/Nicomaru#218 ·
メンテナーはふだん 3 日以内に返信
-
難易度 2/5 1〜3時間 初心者へのやさしさ 72/100
sveltejs/svelte-eslint-parser#1008 ·
メンテナーはふだん 1 日以内に返信
-
難易度 2/5 1〜3時間 初心者へのやさしさ 78/100
silverbucket/inbox-rs#351 ·
メンテナーはふだん 1 日以内に返信
-
[Bug]: The H2 series' and the H3C's fan 3 is labelled "Chamber Fan", Bambu calls it the exhaust fanオープンarea:ui
難易度 2/5 1〜3時間 初心者へのやさしさ 88/100
メンテナーはふだん 1 日以内に返信
-
bug front P2
難易度 2/5 1〜3時間 初心者へのやさしさ 86/100
invoicerr-app/invoicerr#964 ·
メンテナーはふだん 1 日以内に返信