Fix (high): atomic approval consume, expiry + wallet-change guards#795
Fix (high): atomic approval consume, expiry + wallet-change guards#795Rigidity wants to merge 2 commits into
Conversation
`process_after_approval` did a separate `get_pending_approval` then `remove_pending_approval`, each taking the lock independently. Two concurrent `bridgeApprovals.resolve` calls for the same approval id could both observe the record before either removed it, double-executing the gated action (e.g. wallet.sendXch / wallet.getSecretKey) from one user approval. Add `take_pending_approval`, which removes and returns the record under a single lock, and use it so an approval is consumed exactly once. Also reject resolution of an approval whose `expires_at_ms` has passed with an `approval_timeout` error, closing the window between expiry and the next tick of the background expiry loop. Remove the now-unused `get_pending_approval`/`find_pending_approval` helpers.
A wallet.sendXch approval showed the user a transaction for the wallet that was active when the approval was requested, but execution ran against whatever wallet was active at resolve time. Switching wallets between request and approval therefore sent from a different wallet than the one the user reviewed. Record the active wallet fingerprint on the pending approval when it is created, and refuse to execute a sendXch approval if the active wallet no longer matches (error code wallet_changed). getSecretKey is unaffected because it targets an explicit fingerprint from its params.
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes using default effort and found 1 potential issue.
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit 78de31c. Configure here.
| args.reason | ||
| .unwrap_or_else(|| "User denied the request".to_string()), | ||
| ) | ||
| } else if unix_timestamp_ms() as u64 > pending.expires_at_ms { |
There was a problem hiding this comment.
Expiry boundary mismatch on resolve
Medium Severity
The new resolve-time expiry guard rejects only when now is strictly greater than expires_at_ms, while the background expiry loop already treats an approval as expired when expires_at_ms <= now. At the exact expiry timestamp, a late resolve(approved: true) can still run gated actions such as wallet.sendXch even though the loop considers the approval expired.
Additional Locations (1)
Reviewed by Cursor Bugbot for commit 78de31c. Configure here.
|
Cherry-picked minus the comments Can be closed |


Severity: High (groups H1, H2, and the expired-approval gap)
These findings all live in the approval resolve path (
process_after_approval+bridge/state.rs), so they are fixed together.H1 — non-atomic approval consumption (double execution)
process_after_approvalconsumed a pending approval with a separateget_pending_approvalthenremove_pending_approval, each acquiring the mutex independently. Two concurrentbridgeApprovals.resolvecalls for the sameapproval_idcould both clone the record before either removed it, double-executing the gated action (e.g.wallet.sendXch,wallet.getSecretKey) from one user approval.Fix:
take_pending_approvalremoves and returns the record under one lock; the approval is consumed exactly once (None⇒ already consumed/unknown ⇒ error). Removed the now-unusedget_pending_approval/find_pending_approval.Expired-approval gap
Resolution never checked
expires_at_ms, so aresolve(approved: true)that arrived between expiry and the next tick of the background expiry loop still executed.Fix: reject resolution when
unix_timestamp_ms() > expires_at_mswithapproval_timeout.H2 — wallet.sendXch approval not bound to a wallet
A
sendXchapproval showed the user a transaction for the wallet active at request time, but execution ran against whatever wallet was active at resolve time. Switching wallets in between sent from a different wallet than the user reviewed.Fix: record the active wallet fingerprint on the pending approval when created (
approved_fingerprint), and refuse to execute asendXchapproval if the active wallet no longer matches (wallet_changed).getSecretKeyis unaffected because it targets an explicit fingerprint from its params.Files
crates/sage-apps/src/bridge/bridge_request.rscrates/sage-apps/src/bridge/state.rscrates/sage-apps/src/bridge/types.rsTesting
No local build (per request); relying on CI.
Note
High Risk
Touches security-critical bridge approval execution for wallet sends; fixes prior double-execution and wrong-wallet risks but changes behavior on concurrent resolve and late approval.
Overview
Hardens the bridge approval resolve path so gated wallet actions cannot run twice, after timeout, or against a different wallet than the user reviewed.
Resolution now consumes pending approvals atomically via
take_pending_approval(single lock remove-and-return) instead of separate get/remove, so concurrent resolves for the sameapproval_idcannot double-execute methods likewallet.sendXch.On approve, the handler rejects expired approvals (
approval_timeoutwhennow > expires_at_ms) even if the background expiry loop has not run yet, and bindsSendXchapprovals to the active wallet fingerprint captured at request time—resolving after a wallet switch returnswallet_changedinstead of sending from another wallet.GetSecretKeyis unchanged (explicit fingerprint in params).Reviewed by Cursor Bugbot for commit 78de31c. Bugbot is set up for automated code reviews on this repo. Configure here.