Repository navigation
Description
Overview
Even after JWT guards are added to the mutating routes (see the companion "no auth at all" issue), a whole class of these endpoints will still be exploitable, because they derive the identity of who benefits from the action from request-body fields the client controls, not from the authenticated caller (req.user). Adding @UseGuards(JwtAuthGuard) proves someone with a valid token is calling — it does nothing to stop that someone from naming a different person (or a different wallet) as the beneficiary.
Concrete instances:
// src/bounties/dto/claim-bounty.dto.ts
export class ClaimBountyDto {
@IsUUID()
contributorId: string;
}
// src/bounties/bounties.controller.ts:41-45
@Idempotent('bounty.claim')
@Post(':id/claim')
claim(@Param('id') id: string, @Body() dto: ClaimBountyDto) {
return this.bountiesService.claim(id, dto.contributorId);
}contributorId is whatever UUID the client puts in the body — BountiesService.claim (src/bounties/bounties.service.ts:58-66) sets bounty.claimedById = contributorId with no check that it equals the caller. Any authenticated user can claim a bounty as another user's ID — griefing (blocking the real intended contributor, since CLAIMED is a one-way gate per the state machine) or, combined with the setStellarAddress IDOR in the companion issue, redirecting the eventual payout.
// src/escrow/dto/release-escrow.dto.ts / split-release.dto.ts
recipientAddress: string; // free-form string, not tied to any user record
recipientId?: string; // optional, only used for Payment attribution — never cross-checked against recipientAddressEscrowController.release/splitRelease (escrow.controller.ts:26-42) pass both straight through to EscrowService. There is no check anywhere that recipientAddress actually belongs to recipientId, or to the bounty's claimedById, or to anyone authenticated at all. FundEscrowDto.funderAddress and DepositDto.funderAddress (maintenance pools) have the same shape: a free string, unconnected to the caller.
The point isn't "add auth" (that's the companion issue) — it's that these specific fields need to be removed from the client-writable surface and derived from req.user server-side, because a guard alone leaves exactly this gap: user A, correctly authenticated as themselves, calling POST /bounties/:id/claim with {"contributorId": "<user B's id>"}.
Requirements
ClaimBountyDto.contributorIdshould be dropped from the request body;BountiesService.claimshould take the ID fromreq.user.userIdonce auth lands, not from client input. If there's a legitimate "claim on behalf of" flow (e.g. a maintainer assigning a bounty to a contributor), make it an explicit, separately-authorized action, not the default unauthenticated path.- For
recipientAddress/recipientIdon the escrow release endpoints: at minimum, whenrecipientIdis present, validaterecipientAddress === user(recipientId).stellarAddressserver-side rather than trusting the client's pairing of the two. The service-layer callers (BountiesService.markMergedAndRelease) already do this correctly by deriving the address from the user record — the rawEscrowControllerHTTP surface should not allow bypassing that by accepting an arbitrary pair directly. funderAddressfields (FundEscrowDto,DepositDto,FundBountyDto,FundMilestoneDto) should similarly be validated against the authenticated caller's own linkedstellarAddresswhere the caller is expected to be the funder, or explicitly documented/justified as an intentionally open field (e.g. if funding is meant to be permissionless by design — but that should be a stated decision, not an accident of "the DTO happened to accept a string").- Audit every DTO under
src/**/dtofor this pattern (any field named*Idor*Addressthat represents "who benefits" rather than "what resource") and produce a short table in the PR description of what was found and how each was resolved.
Acceptance Criteria
-
bounty.claimno longer accepts a client-suppliedcontributorId— it's derived from the authenticated caller. -
escrow.release/escrow.splitReleasereject arecipientId/recipientAddresspair that doesn't match the user record on file (or the fields are removed in favor of a fully server-derived recipient for the bounty/milestone/pool-mediated paths). - A test proves: authenticated user A cannot cause
bounty.claimedByIdto be set to user B's ID via the claim endpoint. - A test proves: a
recipientId/recipientAddressmismatch on a release call is rejected before any Soroban call is made.
Additional Notes
Precise references:
src/bounties/dto/claim-bounty.dto.ts:1-8andsrc/bounties/bounties.controller.ts:41-45— the claim-spoofing path.src/bounties/bounties.service.ts:58-66—claim()trustscontributorIdunconditionally.src/escrow/dto/release-escrow.dto.ts:1-13andsrc/escrow/dto/split-release.dto.ts:1-37—recipientAddress/recipientIddecoupled, no cross-check.src/escrow/escrow.controller.ts:26-42— passes both straight through.- Contrast with
src/bounties/bounties.service.ts:121-129— the correct pattern already exists in this codebase (derivingrecipientAddressfromcontributor.stellarAddressserver-side for the normal merge-and-release flow); the rawEscrowControllerHTTP surface just doesn't enforce the same discipline, so it's a shortcut around the safe path that already exists one layer up.
Why this survives the companion "add JWT guards" fix on its own: that issue's acceptance criteria stop at "every mutating route requires a valid JWT" and "at least a minimal ownership/role check exists for the highest-value actions" — this issue is the detailed follow-through on what those ownership checks actually need to look like for the specific fields listed here, since "add a guard" and "validate this specific field against this specific user record" are different amounts of work and this issue's checklist should not be considered satisfied by the other one's.
Test/reproduction plan:
- Authenticate as user A. Fund a bounty.
POST /bounties/:id/claimwith{"contributorId": "<B's UUID>"}. Pre-fix: 200,bounty.claimedById === B.id. Post-fix: either the field is rejected/ignored (400 or the ID is silently forced to A) — pick one behavior and test it explicitly. - Authenticate as user A (who has
recipientIdpointing at user B in their own claim).POST /escrow/:id/releasewith{"recipientAddress": "<A's own address>", "recipientId": "<B's id>"}. Assert this is rejected once the cross-check lands, rather than silently paying A while attributing thePaymentrow to B.
Cross-references: direct follow-on to the "no auth at all" issue and the setStellarAddress IDOR issue — all three exploit the same root pattern (trusting client-supplied identity over req.user) at three different layers: no guard at all, a guard with no ownership check on the URL param, and a guard (once added) with no ownership check on body fields.
Activity
davieslennox0 commentedon Aug 16, 2026
I can help resolve the issue, assign and let me work on it.
grantfox-oss commentedon Aug 17, 2026
🦊 GrantFox — @davieslennox0 has been assigned to this issue as part of the Third Campaign campaign!
Next steps:
Closes #40)Good luck! Track your progress on GrantFox.
fix(security): bind money-moving DTOs to authenticated caller identity
fix(security): bind money-moving DTOs to authenticated caller identity
test(MergeFi#60): adapt address-validation e2e specs to the new auth …
security(bounties): bind claim contributor to authenticated JWT caller (
fix(escrow): repair a mangled merge in invokeRelease + update milesto…
fix(milestones): create binds sponsorId to the caller, not the DTO
fix(escrow): repair a mangled merge in invokeRelease + update milesto…
fix(bounties): sponsor is the authenticated caller, not a trusted DTO…
fix(milestones): create binds sponsorId to the caller, not the DTO