From d50d9c8277e5e34537cae0d82a9387f39fb6a326 Mon Sep 17 00:00:00 2001 From: martinzhames Date: Mon, 31 Aug 2026 07:57:04 +0100 Subject: [PATCH 1/2] fix: validate quote() write matches target intent before persisting quote() is intentionally unauthenticated for price discovery, but when dto.intentId is supplied it persists quotedDstAmount onto that intent. Any caller who knows an intent's UUID (public via list/create responses and the WS feed) could overwrite quotedDstAmount with a value computed from an arbitrary, unrelated token pair/amount. Now the request's srcChain/srcToken/dstToken/srcAmount are cross-checked against the target intent's stored fields and a mismatch is rejected with a 400. Kept ownership-agnostic (no signature) rather than requiring proof of ownership, since adding auth to this write path would mean adding signature verification to quote(), which is out of scope and inconsistent with the endpoint remaining public for price discovery. Strict content-matching closes the arbitrary-write vector without changing quote()'s access model. --- src/intents/intents.controller.ts | 34 +++++++++++++++++++++++++++++-- 1 file changed, 32 insertions(+), 2 deletions(-) diff --git a/src/intents/intents.controller.ts b/src/intents/intents.controller.ts index c072a02..f3a5b49 100644 --- a/src/intents/intents.controller.ts +++ b/src/intents/intents.controller.ts @@ -338,7 +338,37 @@ export class IntentsController { description: "Rate limit exceeded — max 20 quote requests per 60 s per IP", }) @ApiOkResponse({ type: QuoteResponseDto }) - quote(@Body() dto: QuoteRequestDto): QuoteResponseDto { + async quote(@Body() dto: QuoteRequestDto): Promise { + // Security fix: quote() is intentionally unauthenticated for price + // discovery, but when dto.intentId is supplied we persist quotedDstAmount + // onto that intent. Without validation, any caller who knows an intent's + // UUID (public — returned from list/create and broadcast over the WS + // feed) could overwrite quotedDstAmount with a value computed from an + // arbitrary, unrelated token pair/amount. We keep this endpoint + // ownership-agnostic (no signature required, matching its public + // price-discovery role) but require the request's src/dst token and + // amount to strictly match the target intent's stored fields, rejecting + // any mismatch. Requiring proof of ownership was considered but rejected: + // it would mean adding signature verification to this endpoint, which is + // explicitly out of scope and inconsistent with quote() remaining public. + let targetIntent: Awaited> = undefined; + if (dto.intentId) { + targetIntent = await this.intentsService.get(dto.intentId); + if (!targetIntent) { + throw new NotFoundException("Intent not found"); + } + const mismatched = + targetIntent.srcChain !== dto.srcChain || + targetIntent.srcToken?.address?.toLowerCase() !== (dto.srcTokenAddress ?? "").toLowerCase() || + targetIntent.dstToken?.contract?.toLowerCase() !== (dto.dstTokenContract ?? "").toLowerCase() || + targetIntent.srcAmount !== dto.srcAmount; + if (mismatched) { + throw new BadRequestException( + "Quote request does not match the target intent's srcChain/srcToken/dstToken/srcAmount", + ); + } + } + const solvers = this.solversService.getAll().filter((s) => s.isActive); // #219: use typed resolveSrcToken / resolveDstToken — no more any casts @@ -418,7 +448,7 @@ export class IntentsController { }) .sort((a, b) => Number(BigInt(b.dstAmount) - BigInt(a.dstAmount))); - if (dto.intentId && quotes.length > 0) { + if (dto.intentId && targetIntent && quotes.length > 0) { await this.intentsService.update(dto.intentId, { quotedDstAmount: quotes[0].dstAmount }); } From 9398e4946776f639367565da4402a268feeb79d5 Mon Sep 17 00:00:00 2001 From: martinzhames Date: Mon, 31 Aug 2026 07:57:28 +0100 Subject: [PATCH 2/2] fix: verify solver signature in IntentsController.accept() accept() checked that the solver was registered, active, and bonded, but never called verifyStellarSignature() despite AcceptIntentDto already carrying a signature field intended for exactly this. Since a solver's public address is public (visible on the leaderboard), any caller could accept an intent "as" that solver by supplying any string >= 10 chars as signature, letting an attacker grief a competitor solver into a fill obligation it never agreed to (and, once slashing lands, get its bond slashed for missing the window). Now accept() calls verifyStellarSignature(dto.solver, buildAcceptMessage(id, dto.solver), dto.signature) before acceptIfOpen(), mirroring fill()'s and cancel()'s existing pattern. scripts/solver-bot.ts already signs via buildAcceptMessage() + sign() and needs no changes. --- src/intents/intents.controller.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/intents/intents.controller.ts b/src/intents/intents.controller.ts index f3a5b49..f9e5ef6 100644 --- a/src/intents/intents.controller.ts +++ b/src/intents/intents.controller.ts @@ -39,6 +39,7 @@ import { ListIntentsDto } from "./dto/list-intents.dto"; import { UserThrottlerGuard } from "./user-throttler.guard"; import { verifyStellarSignature, + buildAcceptMessage, buildCancelMessage, buildFillMessage, } from "../common/stellar-signature"; @@ -229,6 +230,9 @@ export class IntentsController { throw new ForbiddenException("Solver has insufficient bond"); } + // Verify the solver controls the claimed address (mirrors fill()/cancel()). + verifyStellarSignature(dto.solver, buildAcceptMessage(id, dto.solver), dto.signature); + const updated = await this.intentsService.acceptIfOpen(id, dto.solver); if (!updated) { const current = await this.intentsService.get(id);