fix(payment-link): validate ICP OCP tx recipient/amount/asset in the sender-less fallback (BUG-1260 sibling) - #4490
Conversation
|
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 the base branch). Round 1 surfaced one CONFIRMED security bypass and three conformance items:
Round 2 surfaced one PLAUSIBLE follow-up:
Round 3 verified the block-index guard: off-by-one is correct ( Coverage gap noted, not blocking: no unit tests exist for |
f59c3c5 to
106b11b
Compare
|
Update after a full review pass: two more rounds were needed (rebase pulled the parser-rewrite fix from #4453, and a new adversarial pass caught a replay-guard bypass I had missed). 5 review passes total now (two independent lenses per pass: security/correctness and conformance/regression, each re-run from scratch against the base branch). Round 4 caught one CONFIRMED HIGH bypass of the replay guard added in round 2: Number-coerced txId aliases bypass the replay guard. The guard uses TypeORM Fix canonicalizes the txId before both the guard and the ledger call:
Round 5 verified the fix against the full alias table ( The branch was also rebased onto the updated Solana PR head (#4453 got its own round-4 parser-rewrite fix), which resolved one conflict in the comment above Coverage gap noted (unchanged from prior rounds): no unit tests for |
…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.
… lookup Follow-up on the ICP OCP verification fix. Full review surfaced a CONFIRMED HIGH bypass of the replay guard added for BUG-1260's sibling class: The guard used TypeORM `Equal(txId)` — an exact-string SQL match — but both `isTxComplete` and `getTransferByTxId` resolved the block via `Number(txId)`. Distinct strings that Number-coerce to the same block (`"42"`, `"042"`, `"+42"`, ` "42"`, `"42.0"`, `"4.2e1"`, `"0x2a"`) each passed the guard independently and then hit the same real block via the ledger call. If that block was a legit DFX-bound native ICP transfer of ≥ activation.amount, the validator accepted — attacker settled their quote against another user's on-chain payment. Same class hit ICRC-3 (`canA:42` vs `canA:042` vs `canA:4.2e1`). Canonicalize before both the guard and the ledger call: - New `InternetComputerUtil.canonicalizeTxId(txId)` normalizes each of the three ICP formats to one form per underlying block: Rosetta 64-hex → lowercase; ICRC-3 `canisterId:blockIndex` → block-index re-serialized through `Number(...)`; native block-index → same. Non-numeric / unknown shapes are returned untouched (downstream lookup fails). - `doIcpTxIdPayment` runs the canonicalizer at the top, persists the canonical form to the DB via `saveTransaction` if it differs from what `txReceived` stored, and uses the canonical `txId` for the replay guard, `waitForTxConfirmation`, `getTransferByTxId`, and the final `quote.txInBlockchain(txId)`. The catch-block message keeps the raw input for user diagnostics. Effect on the race: two concurrent submissions of aliased txIds both canonicalize to the same string; both `saveTransaction` calls target their own PK-scoped rows; both guards run against the same canonical string and the ORDER BY id ASC tiebreak (from the earlier BUG-1260 fix) deterministically picks the earliest quote. Only ICP has numeric txId formats — Solana signatures are base58 (Number → NaN), EVM hashes are `0x`-prefixed hex 66 chars. Verified no other chain in `PaymentQuoteService` has the same vulnerability class. Verified against the full alias table (`"42"`, `"042"`, `"+42"`, `" 42"`, `"42.0"`, `"4.2e1"`, `"0x2a"` all fold to `"42"`; `"1e2"` correctly stays as `"100"`, i.e. a different block). Downstream consumers (`isTxComplete`, `getTransferByTxId`, reconciliation `getQuoteByTxId`) accept canonical forms. `tsc`, `eslint`, `prettier` clean. 12 test suites / 102 tests pass.
106b11b to
ebf7b74
Compare
|
Follow-up to the previous comment, after a rebase onto #4453's R4.1 (prettier line-wrap): one more full pass ran (two independent lenses, each re-run from scratch against Both lenses returned zero findings on the current head: Security probes on canonicalization edge cases — every path is fail-closed:
Downstream state consistency verified: the ICP pay-in strategy at Conformance pass verified: layering (canonicalize on
Coverage gap unchanged from prior rounds: no unit tests for Stacking: base still |
Problem (High — anonymous payment completion, same class as BUG-1260)
`doIcpPayment` falls back to a "no `sender`" branch whenever the client cannot use the ICRC-2 approve/pull flow — the client self-broadcasts the payment and posts back a raw txId. That fallback routed through `doVerifiedTxIdPayment`, which only called `isTxComplete` — recipient, amount, and asset were never validated. Any authenticated OCP caller could submit any real finalized ICP tx (omit `sender`, pass `?tx=`) and settle a foreign payment obligation.
Client-controlled — attacker just omits one field. Same class as the Solana bug in #4453.
Fix
New `doIcpTxIdPayment` mirrors the Solana handler:
Removes the now-dead `doVerifiedTxIdPayment` — the ICP fallback was its only remaining caller after #4453.
Stacked on #4453
This branch is based on `fix/paymentlink-solana-anonymous-completion` (PR #4453) — it depends on `getEarliestQuoteClaimingTx`, `TxValidationService` seams, and `SolanaService` injection scaffolding introduced there. Merge #4453 first, then this rebases cleanly onto `develop` on merge.
PR completeness
Test plan
Related