fix(security): bind money-moving DTOs to authenticated caller identity - #80
Open
davieslennox0 wants to merge 2 commits into
Open
fix(security): bind money-moving DTOs to authenticated caller identity#80davieslennox0 wants to merge 2 commits into
davieslennox0 wants to merge 2 commits into
Conversation
|
@Kasbit is attempting to deploy a commit to the chonilius' projects Team on Vercel. A member of the Team first needs to authorize it. |
davieslennox0
force-pushed
the
fix/dto-identity-binding
branch
from
August 17, 2026 13:58
5122e21 to
90516b4
Compare
- ClaimBountyDto: removed entirely; POST /bounties/:id/claim now takes no
body and derives the contributor from req.user.userId
- EscrowService: added assertRecipientAddressMatchesUser() cross-check;
release(), splitRelease() and releasePartial() reject a mismatched
recipientId/recipientAddress pair before any Soroban call
- funderAddress: the four funding routes assert the address is the caller's
own linked stellarAddress. req.user carries only {userId, username}, so
this is a user-record lookup (UsersService.assertOwnsStellarAddress), not
a token-claim comparison
- JwtAuthGuard added to exactly the five routes that now derive identity
from the caller. Without a guard req.user is undefined and these checks
would be decorative; the remaining mutating routes are left to the
companion auth issue
- BountiesService: a payout to a contributor with no linked wallet used to
fall back to address '', releasing to nobody while still marking the
bounty PAID; it now fails with a message naming the real problem
- Tests: user-A-cannot-claim-as-B, mismatched pair rejected pre-Soroban
(release/splitRelease/releasePartial), matching pair passes, unlinked and
unknown recipients rejected, funder binding on all four funding routes
- PR description includes the full DTO audit table
Closes MergeFi#40
…binding The MergeFi#60 boundary specs construct their TestingModules from a bare controller list. After MergeFi#40 all four of those controllers inject UsersService and the funding routes carry JwtAuthGuard, so the modules no longer compile and every funding assertion would answer 401 instead of the 400/201 the specs are asserting. Stub the guard to a fixed caller and make the ownership assertion a no-op, so these specs keep testing what they were written to test — StrKey validation at the HTTP boundary — rather than auth. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
davieslennox0
force-pushed
the
fix/dto-identity-binding
branch
from
August 21, 2026 07:02
90516b4 to
75c9c5b
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #40
Description
Fixes the class of vulnerabilities where money-moving DTOs trust client-supplied identity fields instead of binding to the authenticated caller (
req.user).Two rules now hold across the API:
recipientIdandrecipientAddress, the service rejects the pair unless the user record agrees.A note on scope: this needed guards to mean anything
None of the bounty/escrow/milestone/maintenance-pool routes were behind
JwtAuthGuardwhen I started — the codebase says so explicitly inIdempotencyInterceptor's doc comment, and#40acknowledges it ("once auth lands"). That makes AC 1 unreachable on its own: you cannot derive the contributor from the authenticated caller when there is no authenticated caller, and afunderAddresscheck against an undefinedreq.useris worse than no check because it looks like protection.So
JwtAuthGuardis added to exactly the five routes whose identity now comes from the caller, and no others:POST /bounties/:id/claimPOST /bounties/:id/fundPOST /escrow/fundPOST /milestones/:id/fundPOST /maintenance-pools/:id/depositRelease, split-release, refund, milestone-resolve and pool-assign-reward are deliberately not guarded here — their fix is the service-layer cross-check below, which needs no caller identity, and blanket-guarding the mutating surface belongs to the companion auth issue.
One side effect worth knowing:
IdempotencyInterceptor.resolveCallerIdnow returns a realuserIdon those five routes, so their idempotency keys are scoped per user instead of sharing the'anonymous'bucket. Doc comment updated.Changes
ClaimBountyDto— removed outrightThe DTO file is deleted and
POST /bounties/:id/claimtakes no request body at all; the contributor isreq.user.userId. An empty class would have been the smaller diff, but it leaves a slot that invites the field back — with no body parameter there is nothing to spoof, and reintroducing one becomes a visible change rather than a one-line addition.The global
ValidationPiperunswhitelist: true, forbidNonWhitelisted: false, so a client still sending{"contributorId": "<B's id>"}has it silently stripped rather than getting a 400. Per the issue's "pick one behavior and test it explicitly": the field is ignored and the claimant is forced to the caller. That's asserted directly.No "claim on behalf of" path was added. If maintainer-side assignment is wanted it needs its own route and its own authorization check, as the issue suggests.
EscrowService—recipientId/recipientAddresscross-checkassertRecipientAddressMatchesUser()rejects a pair the user record disagrees with, and is called fromrelease(),splitRelease()(per recipient) andreleasePartial()before any Soroban invocation.releasePartial()is not in the issue's list but carries the identical bug: it is what/milestones/:id/issues/:issueId/resolveand/maintenance-pools/:id/assign-rewardreach, and both accept the same client-supplied pair.Deliberate details:
BountiesService.markMergedAndRelease) already derives the address from the user record, so for it the check simply restates an invariant it already holds.recipientIdnaming no user, or one with no linked address, is rejected too. Treating an unlinked (null) address as "matches anything" would reopen the hole.recipientIdremains optional. With no attribution claimed there is no pairing to disprove, and the address stands on its own as before — no existing caller breaks.funderAddress— validated, not documented awayAll four are on endpoints where the caller is the party whose wallet is debited, so naming someone else's address is never a legitimate request. None was left as intentionally permissionless.
The brief expected
req.user.stellarAddress;req.useris only{ userId, username }(JwtStrategy.validate), and a token claim would be a stale snapshot anyway. The check is therefore a user-record lookup —UsersService.assertOwnsStellarAddress()→ForbiddenException— run in the controller before the service is called. A caller with no linked address cannot fund.BountiesService— a latent empty-recipient releasemarkMergedAndReleasefell back torecipientAddress: ''when a payee had no linked wallet, releasing to nobody while still marching the bounty on toPAID. The new escrow check catches that, but would report it as a recipient mismatch; it now fails withuser <id> has no linked Stellar address, naming the actual problem. This is a behaviour change on the merge-webhook path: such a merge now raises instead of attempting an empty release.DTO Audit Table
Every DTO under
src/— note thatFundBountyDto,FundMilestoneDto,DepositDto,AssignRewardDto,ResolveIssueDtoandSetStellarAddressDtoare declared inline in their controllers, not in*.dto.tsfiles, so a search restricted todto/misses six of them.ClaimBountyDtocontributorIdreq.user.userIdReleaseEscrowDtorecipientId+recipientAddressEscrowService.release()SplitRecipientDtorecipientId+recipientAddresssplitRelease()ResolveIssueDto(inline, milestones)recipientId+recipientAddressreleasePartial(). Not listed in the issueAssignRewardDto(inline, pool)recipientId+recipientAddressreleasePartial(). Not listed in the issueFundEscrowDtofunderAddressFundBountyDto(inline)funderAddressFundMilestoneDto(inline)funderAddressDepositDto(inline)funderAddressTeamMemberSplitDtouserIdbounties.service.ts), never paired with a client address. Who may create a team naming arbitrary users is an authorization gap, not an identity-binding one → companion guards issueSetStellarAddressDtostellarAddress(+:idpath param)CreateBountyDtosponsorIdCreateMilestoneDtosponsorIdCreateTeamDtocreatedByIdreq.useronce these routes are guardedCreatePoolDtocreatedByIdCreateBountyDtoissueIdCreateMilestoneDtorepositoryIdCreatePoolDtorepositoryIdFundEscrowDtobountyId,milestoneId,maintenancePoolIdPublicUserDtoDependency worth flagging
This fix is only as strong as the endpoint that links addresses to users.
PATCH /users/:id/stellar-addressstill takes the target from the path param rather thanreq.user, so a caller who can rewrite another user'sstellarAddresscan make the recipient cross-check pass with an address they control. That is the companionsetStellarAddressIDOR issue, and I've left it there rather than fixing another issue's acceptance criteria — but the two should land together to get the intended guarantee. The issue itself anticipates exactly this combination.Tests Added
bounty.claimedByIdto be set to user B's ID — the claimant reaching the service is always the caller, and the handler's arity is asserted so a reintroduced body parameter fails the suiterecipientId/recipientAddressthrowsBadRequestExceptionbefore any Soroban call, onrelease(),splitRelease()(one bad entry poisons the whole split) andreleasePartial(); asserted via the public methods rather than the private helper, so deleting a call site fails the testPaymentwith both fieldsrecipientId) still works; unknown recipient rejected; recipient with anulladdress rejected; rejection message leaks neither address; funder binding enforced on the funding routes;assertOwnsStellarAddressaccepts own / rejects other, unlinked, and vanished users146/146unit tests pass (was 118 passing + 7 failing before — the 7 areescrow-fk-integrity.integration.spec.ts, which needs a live Postgres and passes once one is provided). E2E is unchanged frommain:app.e2e-spec.tsfails on both, as it boots the fullAppModuleand requires real GitHub OAuth credentials.tsc --noEmit,nest buildandeslintare all clean — lint output is byte-identical tomain's (2 pre-existing prettier errors intest/users.e2e-spec.ts, untouched to keep this diff focused).Type of Change
Checklist