fix(payment-link): validate Solana OCP tx recipient/amount/asset (BUG-1260) - #4453
fix(payment-link): validate Solana OCP tx recipient/amount/asset (BUG-1260)#4453davidleomay wants to merge 3 commits into
Conversation
…-1260) Solana routed through `doVerifiedTxIdPayment`, which only checked that the submitted txId was finalized without an error — recipient, amount, and asset were never validated. Any real finalized Solana tx passed as `?tx=<hash>` was enough to flip a quote to TX_BLOCKCHAIN, letting an unrelated tx settle a payment obligation. New `doSolanaTxIdPayment` mirrors the EVM/Firo model: - Wait for finality, then fetch the parsed tx via `SolanaService.getTransaction`. - For each Solana activation on the quote, call the new `TxValidationService.validateSolanaTransaction` — matches a destination that pays the expected owner address for the expected asset (mint disambiguates SPL from SOL) with amount ≥ activation.amount. Overpayment accepted; wrong owner / wrong mint / underpayment fail. - `SolanaTransactionDto.destinations[].to` carries the wallet owner in every branch of the parser (create/closeAccount/transfer/transferChecked via updateTokenInstruction), so the same owner-equality check works for both native SOL and SPL — no ATA derivation needed. Also closes a secondary tx-replay class the report implied but didn't exercise: without a unique index on `PaymentQuote.txId`, a legitimate DFX-bound tx could satisfy an unrelated future quote. New `getEarliestQuoteClaimingTx` returns the oldest (ORDER BY id ASC) quote in any tx-active state OR TX_FAILED — the latter matters because `txFailed()` does not clear `txId`, so a failed quote still lays claim. `doSolanaTxIdPayment` now rejects a submission whose earliest claimant is a different quote. Two spec files updated: added SolanaService mock provider (new constructor dep on PaymentQuoteService). Out of scope, worth follow-ups: `SolanaClient.createTransactionDto` still mis-classifies native+SPL mixed txs (any system-program instruction routes the whole tx into the native branch and drops SPL) and merges multi-SPL transfers via last-wins in `getTokenInstructions/updateTokenInstruction`. Both are pre-existing parser DoS risks on legit payments, not payment-side security bypasses. Verified: lint, type-check, payment-link jest suite (10 files / 81 tests) pass.
|
3 review passes to reach zero findings (two independent lenses per pass: security/correctness and conformance/regression, each re-run from scratch on the then-current HEAD against Round 1 was a self-consistent but wrong draft — I derived the recipient's ATA and matched it against Round 2 replaced ATA derivation with owner-equality on both native and SPL paths, removed the now-dead Round 3 surfaced two more issues on that guard:
Round 3 also confirmed the isolation-race bound: with Two pre-existing
Both are parser DoS on legit inputs, not payment-side security bypasses; documented for a separate parser-hardening PR. Coverage gap noted, not blocking: no unit test exists for |
…→owner via postTokenBalances
Follow-up on the BUG-1260 fix. Full review surfaced a CONFIRMED HIGH bypass in
the parser this fix relies on:
`SolanaClient.getTokenInstructions` merged fields across a single tx's parsed
token instructions into ONE `Partial<SolanaTokenInstructionsDto>` via a switch:
`create` set `{mint, source, destination}`, `transfer`/`transferChecked` set
`{authority, amount}`. A single tx bundling both let an attacker glue DFX's
destination + mint (from a `createAssociatedTokenAccountIdempotent(DFX, mint)`,
rent ~$0.005) to an unrelated transfer's amount (a `transferChecked` from
attacker to attacker for the required amount). The merged DTO looked like a
real payment to DFX; `validateSolanaTransaction` accepted; quote flipped to
`TX_BLOCKCHAIN`. DFX received zero.
Rewrote the SPL parser path:
- New `getTokenTransactionDestinations` iterates ALL parsed instructions and
emits one destination per `transfer` / `transferChecked` — not one merged
destination for the whole tx. `create` / `closeAccount` are ignored (no
value transfer; real "create and fund" txs also carry the transfer that
provides the destination + amount).
- Each transfer's destination ATA is resolved to its wallet owner by looking
up `postTokenBalances[b].accountIndex → accountKeys[..].pubkey`, and its
mint is read from the same balance entry. Callers still match on
wallet-owner equality (the model `validateSolanaTransaction` already
expects).
- Removed the now-dead `getTokenInstructions`, `updateTokenInstruction`, and
`getTokenTransactionDestination`, plus the unused `SolanaTokenInstructionsDto`
import in the client (the DTO stays exported for public API stability).
- Updated the two stale "getTokenInstructions/updateTokenInstruction" comment
references in `tx-validation.service.ts` and `payment-quote.service.ts` to
describe the new resolution path.
Verified against six scenarios (original exploit, legit create+fund, plain
transfer, multi-transfer, cross-mint substitution, missing postTokenBalances)
— every scenario resolves correctly or fails closed. CPI-hidden transfers
remain invisible to the outer-instruction loop (pre-existing behavior); a
hidden transfer produces no DFX destination → validator rejects.
`tsc`, `eslint`, `prettier` clean. `payment-link` (81/81) and
`solana-client.spec.ts` (5/5) pass.
|
Update after a full review pass: one more round was needed. 4 review passes total now (two independent lenses per pass: security/correctness and conformance/regression, each re-run from scratch against Round 4 caught a CONFIRMED HIGH bypass that survived rounds 1–3 because I had scoped it out of the earlier review as "pre-existing SolanaClient parser DoS" — it isn't just DoS, it's the same bypass class BUG-1260 is meant to close: Parser instruction-merge bypass. Fix rewrote the SPL parser path ( Round 4 also verified against six scenarios: original exploit (blocked), legit create+fund (works), plain transfer (works), multi-transfer in one tx (each emitted separately, validator matches the DFX one), cross-mint substitution (rejected on mint mismatch), missing Two adjacent items noted, not addressed here (pre-existing, separate follow-ups):
Also from round 4, three conformance nits worth addressing in a follow-up rather than this PR: |
…sender-less fallback (BUG-1260 sibling) Same class as the Solana fix (BUG-1260). `doIcpPayment` falls back to a "no sender" branch whenever the client cannot use the ICRC-2 approve/pull flow — it posts back a raw txId and the server settles from that. That fallback used to route through `doVerifiedTxIdPayment`, which only checked finality via `isTxComplete` — recipient, amount, and asset were never validated, so any authenticated caller could submit any real finalized ICP tx (`?tx=<hash>` and omit `sender`) and mark a payment as settled. New `doIcpTxIdPayment` mirrors the Solana handler: - Same replay guard via `getEarliestQuoteClaimingTx` (rejects a tx already claimed by a different quote, TX_* + TX_FAILED, ORDER BY id ASC). - Waits for finality. - Iterates the ICP activations and, for ICRC-3, rejects up-front when the txId's canister id (`canisterId:blockIndex`) does not match `activation.asset.chainId` — without this, a legit transfer of N units of a cheap ICRC-3 token to the DFX principal would satisfy a quote for N units of a different (expensive) ICRC-3 token (same recipient, same amount). - Fetches the transfer via new `InternetComputerClient.getTransferByTxId`, which handles all three ICP txId formats (Rosetta 64-hex hash, ICRC-3 `canisterId:blockIndex`, native block-index) and normalizes them into `IcpTransfer`. Asserts the returned block index matches the requested one (fail-closed against archive-skip in `getIcrcTransfers`/`getTransfers` that could otherwise substitute a later block into a foreign quote). - Normalizes the expected recipient to match the format the transfer carries (native = account-identifier hex via `InternetComputerUtil.accountIdentifier`; ICRC-3 = raw principal text) and calls new `TxValidationService.validateIcpTransfer`. Overpayment accepted, wrong recipient / underpayment fails. Removes the now-dead `doVerifiedTxIdPayment` — the ICP fallback was its only remaining caller after the Solana fix. Depends on the Solana PR (#4453) for `getEarliestQuoteClaimingTx` and the validator/service seams; branch is stacked on it. Verified: lint, type-check, payment-link + icp + solana jest suites (12 files / 102 tests) pass.
…sender-less fallback (BUG-1260 sibling) Same class as the Solana fix (BUG-1260). `doIcpPayment` falls back to a "no sender" branch whenever the client cannot use the ICRC-2 approve/pull flow — it posts back a raw txId and the server settles from that. That fallback used to route through `doVerifiedTxIdPayment`, which only checked finality via `isTxComplete` — recipient, amount, and asset were never validated, so any authenticated caller could submit any real finalized ICP tx (`?tx=<hash>` and omit `sender`) and mark a payment as settled. New `doIcpTxIdPayment` mirrors the Solana handler: - Same replay guard via `getEarliestQuoteClaimingTx` (rejects a tx already claimed by a different quote, TX_* + TX_FAILED, ORDER BY id ASC). - Waits for finality. - Iterates the ICP activations and, for ICRC-3, rejects up-front when the txId's canister id (`canisterId:blockIndex`) does not match `activation.asset.chainId` — without this, a legit transfer of N units of a cheap ICRC-3 token to the DFX principal would satisfy a quote for N units of a different (expensive) ICRC-3 token (same recipient, same amount). - Fetches the transfer via new `InternetComputerClient.getTransferByTxId`, which handles all three ICP txId formats (Rosetta 64-hex hash, ICRC-3 `canisterId:blockIndex`, native block-index) and normalizes them into `IcpTransfer`. Asserts the returned block index matches the requested one (fail-closed against archive-skip in `getIcrcTransfers`/`getTransfers` that could otherwise substitute a later block into a foreign quote). - Normalizes the expected recipient to match the format the transfer carries (native = account-identifier hex via `InternetComputerUtil.accountIdentifier`; ICRC-3 = raw principal text) and calls new `TxValidationService.validateIcpTransfer`. Overpayment accepted, wrong recipient / underpayment fails. Removes the now-dead `doVerifiedTxIdPayment` — the ICP fallback was its only remaining caller after the Solana fix. Depends on the Solana PR (#4453) for `getEarliestQuoteClaimingTx` and the validator/service seams; branch is stacked on it. Verified: lint, type-check, payment-link + icp + solana jest suites (12 files / 102 tests) pass.
|
Follow-up to the previous comment, after the R4 parser rewrite plus a prettier line-wrap: one more full pass ran (two independent lenses, each re-run from scratch against Fresh security probes on the rewritten parser and the still-present replay guard — all fail-closed:
Pre-existing weakness noted (not introduced or worsened by this PR, worth a separate follow-up ticket): an attacker can Conformance pass surfaced two minor items and a doc drift, none blocking:
CI: all 12 checks green after the prettier line-wrap. |
Problem (High — anonymous payment completion)
Solana OCP tx-completion (
?tx=<hash>on `payment-link` payments) accepted any real, finalized Solana tx. The switch case routed through `doVerifiedTxIdPayment`, which only called `SolanaClient.isTxComplete` — finality + no error, no recipient/amount/asset binding. Any unrelated finalized Solana tx flipped a real payment obligation to `TX_BLOCKCHAIN` (settlement).Reported by external hunter (BUG-1260, Compass Security).
Fix
New `doSolanaTxIdPayment` mirrors the EVM/Firo path:
Also closes a secondary tx-replay class implied by the report but not exercised in the PoC: without a unique index on `PaymentQuote.txId`, a legitimate DFX-bound tx could satisfy an unrelated future quote for the same asset+amount. New `getEarliestQuoteClaimingTx` returns the oldest (ORDER BY id ASC) quote in any tx-active state OR `TX_FAILED` — the latter matters because `txFailed()` does not clear `txId`, so a failed quote still lays claim. `doSolanaTxIdPayment` rejects a submission whose earliest claimant is a different quote (`uniqueId` mismatch); self-match (retry of same quote) passes.
Out of scope (follow-ups)
`SolanaClient.createTransactionDto` mis-classifies native+SPL mixed txs (any system-program instruction routes the whole tx into the native branch and drops SPL) and merges multi-SPL transfers via last-wins in `getTokenInstructions`/`updateTokenInstruction`. Pre-existing DoS risks on legit payments, not payment-side security bypasses. Should be filed as separate parser hardening.
PR completeness
Test plan
Related