fix(rebalancer): validate partner-API responses before signing/transferring - #3728
fix(rebalancer): validate partner-API responses before signing/transferring#3728droplet-rl wants to merge 2 commits into
Conversation
…erring - BridgeApi (bridge.xyz): the escrow to_address returned by /v0/transfers is used verbatim as the on-chain ERC20 transfer recipient from the rebalancer hot wallet, with no runtime validation of the response. Verify the response echoes the requested route (payment_rail/currency) and that the escrow address is a well-formed EVM address before returning it. - Paxos Transit: erc20_approve calldata was submitted verbatim and eip2612_permit domain/value were signed verbatim from the hot wallet. Decode the approve calldata and require selector == approve, the expected spender, and a bounded amount; and validate the permit's verifyingContract/chainId/ owner/spender and a bounded value against the requested parameters before signing. This prevents a compromised/misbehaving endpoint from redirecting an approval, targeting an unexpected token, or authorizing an unbounded amount. (The order-quote target allowlist in the Paxos Transit bridges is a separate follow-up.) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 614b109933
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| assert( | ||
| isDefined(escrowInstructions) && | ||
| escrowInstructions.payment_rail === this.srcNetwork && | ||
| escrowInstructions.currency === srcTokenSymbol, |
There was a problem hiding this comment.
Validate the returned destination before funding the escrow
When Bridge returns a stale, corrupted, or malicious transfer whose source rail/currency matches the request but whose destination address, currency, or payment rail differs, these assertions pass and the hot wallet funds that transfer's valid escrow address. The funds can then settle to the destination embedded in the response rather than the requested toAddress; validate the returned destination fields and amount against data before returning the escrow.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Good catch, fixed in d6d8839.
You're right that the source-route check alone was insufficient: funding the escrow commits the transfer to whatever destination that transfer carries, so a response echoing payment_rail/currency on the source side while naming a different destination would still have settled the funds somewhere we never requested. Now validating the full round trip before returning the escrow:
destination.payment_rail === this.dstNetworkdestination.currency === dstTokenSymboldestination.to_addressmatches the requestedtoAddress- the echoed
amountmatchesnormalizedAmount
One subtlety on the recipient comparison: destinations are not always EVM hex (Tron is in NETWORK_NAMES and uses case-sensitive base58), so destinationAddressesMatch only relaxes to a case-insensitive compare when both sides are hex addresses, and otherwise requires exact equality. Amounts are compared numerically rather than as strings so that trailing-zero normalisation (100.0000 vs 100) doesn't spuriously fail.
Covered by eight new cases in test/generic-adapters/BridgeApi.ts exercising the real BridgeApiClient with a stubbed postWithRetry — redirected recipient, wrong destination currency/rail, wrong source route, mismatched amount, malformed escrow, plus the two positive paths.
| permitData: PaxosTransitPermitData, | ||
| params: { spenderAddress: string; tokenAddress: string; userAddress: string; chainId: number } | ||
| ): void { | ||
| const { domain, value } = permitData; |
There was a problem hiding this comment.
Validate the EIP-712 schema before signing it
When the Paxos endpoint is compromised, checking properties in value is insufficient because _signTypedData signs only fields selected by the API-controlled types, and ethers ignores extra properties. The endpoint can therefore supply another token-supported schema such as an EIP-3009 authorization, include unused matching owner/spender properties to pass these checks, and obtain a signature authorizing a transfer to an attacker; require the exact EIP-2612 Permit field schema and consistent deadline before signing.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Confirmed and fixed in d6d8839 — this was the most serious of the three.
I verified the mechanism against ethers v5 directly before fixing: given types describing an EIP-3009 TransferWithAuthorization and a value carrying decoy owner/spender properties, _TypedDataEncoder infers TransferWithAuthorization as the primary type, silently ignores the decoy properties, and _signTypedData returns a valid signature over the transfer authorization. The PR's value-only checks passed the whole way through. USDC supports EIP-3009, so this was reachable on the actual mainnet route.
The fix pins the schema rather than inspecting the payload. assertCanonicalEip2612PermitTypes requires that, after stripping the descriptive EIP712Domain entry, types contains exactly one entry named Permit whose fields are exactly owner, spender, value, nonce, deadline in order with the canonical types. Anything else — an extra type that could become the primary type, a renamed field, a changed field type — is rejected before signing.
Also added the deadline consistency check you flagged: permitData.deadline is forwarded verbatim to the order-quote endpoint alongside the signature, so it now has to equal the value.deadline we actually signed, otherwise the submitted permit wouldn't match the signature.
I left domain.name/version unvalidated deliberately — verifyingContract and chainId are the security boundary, and a wrong name/version only produces a permit that fails on-chain rather than one that authorizes something unintended. Happy to tighten if you'd rather fail fast there.
Regression tests in PaxosTransitBridge.ts cover the exact EIP-3009 decoy payload above, a tampered Permit field list, a mismatched deadline, and a permit bound to the wrong token; each asserts nothing was signed or broadcast from the hot wallet.
|
|
||
| const approve = auth.methods.find((method) => method.type === "erc20_approve"); | ||
| if (isDefined(approve?.transaction?.encoded)) { | ||
| assertValidPaxosTransitApprove(approve.transaction.encoded, params.spenderAddress); |
There was a problem hiding this comment.
Update the Paxos positive-path test fixtures
Whenever the checked-in Paxos adapter suite runs, the new validators break two existing positive-path tests: the approval fixture supplies 0xapprove, and the permit fixture uses owner/spender addresses that differ from the requested signer and station. Running RELAYER_TEST=true hardhat test --no-compile test/generic-adapters/PaxosTransitBridge.ts produces 10 passing and 2 failing tests at these validation calls, so the fixtures must be encoded with valid matching authorization data.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Reproduced exactly as described — RELAYER_TEST=true hardhat test --no-compile test/generic-adapters/PaxosTransitBridge.ts gave 10 passing / 2 failing at those two validation calls. Fixed in d6d8839.
- The approval fixture's placeholder
0xapproveis now real calldata built with anapprove(address,uint256)interface targeting the station address andMAX_SAFE_ALLOWANCE. - The permit fixture now derives
ownerfroml1Signer.getAddress()(which is whatPaxosTransitBridgepasses asuserAddress) andspenderfrom the station address, instead of the0x…0001/0x…0002placeholders.
Both fixtures moved into shared encodeApprove / buildPermitData helpers so the new negative tests can override single fields without duplicating the whole blob.
That suite is now 18 passing / 0 failing. Across all of test/generic-adapters/, the branch went from 25 passing / 9 failing to 41 passing / 7 failing; the remaining 7 are pre-existing failures on this branch, unrelated to the PR (missing compiled contract artifacts under --no-compile, in Arbitrum/Linea/OpStack/Polygon/USDC/zkSync/AdapterManager). I confirmed that by stashing the changes and re-running against the unmodified PR head.
…x fixtures Addresses the Codex review on #3728. - BridgeApi: validating only the source rail/currency left the destination unchecked, so a response echoing the requested source while naming a different destination address/currency/rail would still have had its escrow funded, settling to a destination we never asked for. Validate the full round trip -- destination rail, currency and recipient, plus the echoed amount -- before returning the escrow. Destination recipients are compared case-insensitively only when both sides are EVM hex, since Tron uses case-sensitive base58. - Paxos Transit: checking properties on `value` was insufficient, because _signTypedData signs only the fields named by the API-controlled `types` and ethers ignores everything else. A compromised endpoint could supply another token-supported schema (e.g. EIP-3009 TransferWithAuthorization) with decoy owner/spender properties to pass the value checks and obtain a signature authorizing a transfer to an arbitrary recipient; verified locally against ethers v5. Pin `types` to the canonical EIP-2612 Permit schema before signing, and require the returned deadline to match the one actually signed (it is forwarded to the order-quote endpoint alongside the signature). - Tests: the approval fixture used the placeholder `0xapprove` and the permit fixture used owner/spender addresses that never matched the requested signer/station, so both positive-path tests failed against the new validators. Encode real approve calldata and derive the permit from the signer/station. Adds negative coverage for each rejection path, including the EIP-3009 schema-confusion vector. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
@codex thanks — all three findings were real and reproducible. Fixed in d6d8839; replies are on each inline thread, summary here. 1. Transfer destination unvalidated (BridgeUtils) — P1, valid. Validating only the source rail/currency left the destination open: funding the escrow commits the transfer to whatever destination that transfer carries, so a response echoing the source route while naming a different destination would still have settled the funds elsewhere. Now validating the full round trip — destination rail, currency and recipient, plus the echoed amount — before returning the escrow. Destination recipients are compared case-insensitively only when both sides are EVM hex, since Tron (also in 2. EIP-712 schema unvalidated (PaxosTransitUtils) — P1, valid, and the most serious. I verified the mechanism against ethers v5 before fixing: with 3. Broken positive-path fixtures — valid. Reproduced your exact 10 passing / 2 failing. The Testing. Added 14 regression tests covering each rejection path, including the EIP-3009 schema-confusion payload; the Paxos ones assert nothing was signed or broadcast from the hot wallet once validation fails. Two judgement calls worth a human eye:
The order-quote target allowlist noted in the original commit message is still a separate follow-up. Please re-review the updated diff. |
|
Codex Review: Didn't find any major issues. Keep them coming! Reviewed commit: ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
If Codex has suggestions, it will comment; otherwise it will react with 👍. Codex can also answer questions or update the PR. Try commenting "@codex address that feedback". |
|
@codex this pass reviewed the wrong commit, so I don't think it clears anything — flagging rather than taking it as a green light. The comment reports Reviewed commit: The fix is in
Timeline for reference: review on No code changes in response to this comment — there's nothing to act on. Re-review |
|
Codex Review: Didn't find any major issues. Swish! Reviewed commit: ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
If Codex has suggestions, it will comment; otherwise it will react with 👍. Codex can also answer questions or update the PR. Try commenting "@codex address that feedback". |
|
Confirmed — this pass reviewed No code changes in response. Recap of where this landed:
Two judgement calls from earlier still stand and are worth a human eye at merge time, since an automated clean pass doesn't settle either:
The order-quote target allowlist noted in the original commit message remains a separate follow-up. Ready for human review. |
The rebalancer acts on two partner-API responses using its hot wallet with no runtime validation of the response body. Both are hardened here.
bridge.xyz (
BridgeApiClient.createTransferRouteEscrowAddress). Thesource_deposit_instructions.to_addressreturned by/v0/transfersis used verbatim as the recipient of an on-chain ERC20transferfrom the rebalancer/withdraw hot wallet, andpostWithRetryperforms no schema validation. Now verifies the response echoes the requested route (payment_rail/currency) and that the escrow address is a well-formed EVM address before returning it, so a corrupted or incorrect response can't send funds to a non-bridge address.Paxos Transit (
resolvePaxosTransitAuthorization). Theerc20_approvetransaction.encodedwas submitted verbatim as calldata, and theeip2612_permitdomain/valuewere passed straight into_signTypedData— both with no validation. Now:approve(spender, amount)withspenderequal to the requested spender and a bounded amount;verifyingContract/chainId/owner/spenderand a boundedvaluebefore signing.This prevents a compromised or misbehaving endpoint from redirecting an approval to an arbitrary spender, targeting an unexpected token, or authorizing an unbounded amount. The order-quote
{to,data,value}target allowlist inPaxosTransitBridge/PaxosTransitL2Bridgeis left as a separate follow-up.Build (
tsc --build), prettier and eslint pass.