Skip to content

Fix (high): atomic approval consume, expiry + wallet-change guards#795

Open
Rigidity wants to merge 2 commits into
appsfrom
fix/apps-approval-atomic-expiry
Open

Fix (high): atomic approval consume, expiry + wallet-change guards#795
Rigidity wants to merge 2 commits into
appsfrom
fix/apps-approval-atomic-expiry

Conversation

@Rigidity

@Rigidity Rigidity commented Jul 3, 2026

Copy link
Copy Markdown
Collaborator

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_approval consumed a pending approval with a separate get_pending_approval then remove_pending_approval, each acquiring the mutex independently. Two concurrent bridgeApprovals.resolve calls for the same approval_id could 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_approval removes and returns the record under one lock; the approval is consumed exactly once (None ⇒ already consumed/unknown ⇒ error). Removed the now-unused get_pending_approval / find_pending_approval.

Expired-approval gap

Resolution never checked expires_at_ms, so a resolve(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_ms with approval_timeout.

H2 — wallet.sendXch approval not bound to a wallet

A sendXch approval 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 a sendXch approval if the active wallet no longer matches (wallet_changed). getSecretKey is unaffected because it targets an explicit fingerprint from its params.

Files

  • crates/sage-apps/src/bridge/bridge_request.rs
  • crates/sage-apps/src/bridge/state.rs
  • crates/sage-apps/src/bridge/types.rs

Testing

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 same approval_id cannot double-execute methods like wallet.sendXch.

On approve, the handler rejects expired approvals (approval_timeout when now > expires_at_ms) even if the background expiry loop has not run yet, and binds SendXch approvals to the active wallet fingerprint captured at request time—resolving after a wallet switch returns wallet_changed instead of sending from another wallet. GetSecretKey is 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.

Rigidity added 2 commits July 3, 2026 18:44
`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.
@Rigidity Rigidity changed the title Fix (high): consume bridge approvals atomically, reject expired ones Fix (high): atomic approval consume, expiry + wallet-change guards Jul 3, 2026

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes using default effort and found 1 potential issue.

Fix All in Cursor

❌ 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 {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 78de31c. Configure here.

@Hadamcik

Hadamcik commented Jul 4, 2026

Copy link
Copy Markdown
Contributor

Cherry-picked minus the comments
Hadamcik@821de3f

Can be closed

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants