Overview
PATCH /users/:id/stellar-address is the one route in the app that does sit behind JwtAuthGuard — but authentication is not authorization, and this route never checks that the :id in the URL matches the caller who owns the token.
// src/users/users.controller.ts:27-35
@ApiBearerAuth()
@UseGuards(JwtAuthGuard)
@Patch(':id/stellar-address')
setStellarAddress(
@Param('id') id: string,
@Body() dto: SetStellarAddressDto,
) {
return this.usersService.setStellarAddress(id, dto.stellarAddress);
}
The handler never injects @Req()/req.user, so it has no way to compare the caller's identity to id even if it wanted to — and UsersService.setStellarAddress doesn't either:
// src/users/users.service.ts:92-99
async setStellarAddress(
userId: string,
stellarAddress: string,
): Promise<User> {
const user = await this.findById(userId);
user.stellarAddress = stellarAddress;
return this.userRepo.save(user);
}
JwtStrategy.validate (src/auth/strategies/jwt.strategy.ts:22-24) attaches { userId, username } to req.user — that's the only place the caller's real identity lives, and this endpoint ignores it entirely.
stellarAddress is the single field that determines where bounty payouts physically go. BountiesService.markMergedAndRelease (src/bounties/bounties.service.ts:121-129) looks it up fresh at payout time, not at claim time:
const contributor = await this.userRepo.findOne({ where: { id: bounty.claimedById } });
await this.escrowService.release(bounty.escrowId, contributor?.stellarAddress ?? '', bounty.claimedById);
Put together: any user who can obtain a valid JWT for their own account (i.e. anyone who completes GitHub OAuth login — a two-minute, self-serve process) can call PATCH /users/<victim-userId>/stellar-address with their own wallet address, and the next time any bounty claimed by the victim gets merged, the payout goes to the attacker's wallet instead. The victim did the work; the attacker gets paid. This requires zero access to the victim's account, session, or GitHub credentials — only the victim's user UUID, which is exposed by the unauthenticated GET /users and GET /bounties/:id (claimedById) endpoints.
Requirements
setStellarAddress must verify the authenticated caller (req.user.userId) equals the :id being modified, and reject with 403 Forbidden otherwise — or, if an admin/maintainer override is intended, gate that path behind an explicit role check (UserRole.MAINTAINER), not "any bearer token."
- Audit every other route in the codebase that takes a resource
:id from the URL and mutates it, to confirm none of them have the same "authenticated but not authorized" shape once guards are added per the companion "no auth at all" issue — this bug class (checking a valid token instead of the right caller) is easy to reintroduce if the general fix for that issue is "just slap @UseGuards(JwtAuthGuard) on everything" without also auditing for ownership.
- Add a regression test that specifically proves cross-user modification is rejected: user A's token, user B's
:id, expect 403.
Acceptance Criteria
Additional Notes
Precise references:
src/users/users.controller.ts:27-35 — the vulnerable handler; no @Req() parameter at all.
src/users/users.service.ts:92-99 — setStellarAddress takes userId purely as a lookup key, never as something to compare against a caller.
src/auth/strategies/jwt.strategy.ts:22-24 — confirms req.user shape is { userId, username }, so the fix is a one-line comparison once @Req() is added: if (req.user.userId !== id) throw new ForbiddenException().
src/bounties/bounties.service.ts:121-129 — confirms the attack actually pays off: stellarAddress is re-read at release time, not pinned at claim time, so the attacker doesn't even need to time the write before the claim — any time before the PR merges works.
src/common/entities/user.entity.ts:38-39 — stellarAddress's own doc comment says "Custody of the corresponding secret key always remains with the user... MergeFi never stores private keys for end users" — true, but irrelevant to this bug: the attacker never needs the victim's key, only write access to a public-key field that has no ownership check.
Why this is worse than a typical IDOR: most IDORs leak or corrupt data. This one redirects real money to an address of the attacker's choosing, is invisible to the victim until they notice they were never paid, and requires no special access beyond "has a GitHub account and completed OAuth once" — the lowest possible bar in a platform whose whole premise is open GitHub-based bounty claiming.
Test/reproduction plan:
- Register two users (A, B) via the OAuth flow (or directly via
UsersService.upsertFromGithub in a test).
- Sign a JWT for A.
- Call
PATCH /users/<B.id>/stellar-address with A's token and an attacker-controlled address.
- Pre-fix: 200,
B.stellarAddress overwritten. Post-fix: 403, B.stellarAddress unchanged.
- End-to-end variant: fund a bounty, have B claim it, have A overwrite B's
stellarAddress, merge the PR, assert the release call would target A's address pre-fix / is rejected post-fix.
Cross-references: this is the second concrete exploit (after the general "no auth at all" issue) of the broader pattern this batch keeps surfacing — the app conflates "has a valid token" with "is allowed to do this." The companion issue on client-supplied contributorId/recipientId/funderAddress fields is the same pattern one layer further out (trusting the body instead of trusting the URL param, but the same underlying missing check against req.user).
Overview
PATCH /users/:id/stellar-addressis the one route in the app that does sit behindJwtAuthGuard— but authentication is not authorization, and this route never checks that the:idin the URL matches the caller who owns the token.The handler never injects
@Req()/req.user, so it has no way to compare the caller's identity toideven if it wanted to — andUsersService.setStellarAddressdoesn't either:JwtStrategy.validate(src/auth/strategies/jwt.strategy.ts:22-24) attaches{ userId, username }toreq.user— that's the only place the caller's real identity lives, and this endpoint ignores it entirely.stellarAddressis the single field that determines where bounty payouts physically go.BountiesService.markMergedAndRelease(src/bounties/bounties.service.ts:121-129) looks it up fresh at payout time, not at claim time:Put together: any user who can obtain a valid JWT for their own account (i.e. anyone who completes GitHub OAuth login — a two-minute, self-serve process) can call
PATCH /users/<victim-userId>/stellar-addresswith their own wallet address, and the next time any bounty claimed by the victim gets merged, the payout goes to the attacker's wallet instead. The victim did the work; the attacker gets paid. This requires zero access to the victim's account, session, or GitHub credentials — only the victim's user UUID, which is exposed by the unauthenticatedGET /usersandGET /bounties/:id(claimedById) endpoints.Requirements
setStellarAddressmust verify the authenticated caller (req.user.userId) equals the:idbeing modified, and reject with403 Forbiddenotherwise — or, if an admin/maintainer override is intended, gate that path behind an explicit role check (UserRole.MAINTAINER), not "any bearer token.":idfrom the URL and mutates it, to confirm none of them have the same "authenticated but not authorized" shape once guards are added per the companion "no auth at all" issue — this bug class (checking a valid token instead of the right caller) is easy to reintroduce if the general fix for that issue is "just slap@UseGuards(JwtAuthGuard)on everything" without also auditing for ownership.:id, expect 403.Acceptance Criteria
PATCH /users/:id/stellar-addressrejects any request where the authenticated caller's ID doesn't match:id(absent an explicit, tested maintainer-override path).user.stellarAddress.UsersController/UsersServiceno longer trust the URL-suppliedidas the target for identity-sensitive writes without validating it againstreq.user.Additional Notes
Precise references:
src/users/users.controller.ts:27-35— the vulnerable handler; no@Req()parameter at all.src/users/users.service.ts:92-99—setStellarAddresstakesuserIdpurely as a lookup key, never as something to compare against a caller.src/auth/strategies/jwt.strategy.ts:22-24— confirmsreq.usershape is{ userId, username }, so the fix is a one-line comparison once@Req()is added:if (req.user.userId !== id) throw new ForbiddenException().src/bounties/bounties.service.ts:121-129— confirms the attack actually pays off:stellarAddressis re-read at release time, not pinned at claim time, so the attacker doesn't even need to time the write before the claim — any time before the PR merges works.src/common/entities/user.entity.ts:38-39—stellarAddress's own doc comment says "Custody of the corresponding secret key always remains with the user... MergeFi never stores private keys for end users" — true, but irrelevant to this bug: the attacker never needs the victim's key, only write access to a public-key field that has no ownership check.Why this is worse than a typical IDOR: most IDORs leak or corrupt data. This one redirects real money to an address of the attacker's choosing, is invisible to the victim until they notice they were never paid, and requires no special access beyond "has a GitHub account and completed OAuth once" — the lowest possible bar in a platform whose whole premise is open GitHub-based bounty claiming.
Test/reproduction plan:
UsersService.upsertFromGithubin a test).PATCH /users/<B.id>/stellar-addresswith A's token and an attacker-controlled address.B.stellarAddressoverwritten. Post-fix: 403,B.stellarAddressunchanged.stellarAddress, merge the PR, assert the release call would target A's address pre-fix / is rejected post-fix.Cross-references: this is the second concrete exploit (after the general "no auth at all" issue) of the broader pattern this batch keeps surfacing — the app conflates "has a valid token" with "is allowed to do this." The companion issue on client-supplied
contributorId/recipientId/funderAddressfields is the same pattern one layer further out (trusting the body instead of trusting the URL param, but the same underlying missing check againstreq.user).