harden: approval verification compares against the dApp's request, not against what the popup displayed #216

Closed
opened 2026-08-11 14:40:20 +02:00 by clawbot · 2 comments
Collaborator

verifySignedTx compares the signed transaction against the dApp's request object. It never compares against the values the popup actually rendered to the user.

For every field the dApp omitted — normally all of nonce, gasLimit and the fee fields, because populateTransaction() fills them in the popup — the number the user read on screen is verified by nothing. #174 works around this with absolute ceilings instead of equality, which bounds the absurd but not the ruinous: a bare 21,000-gas transfer at the current MAX_FEE_PER_GAS ceiling hands the validator 2.1 ETH, and MAX_GAS_LIMIT * MAX_FEE_PER_GAS bounds the worst case at 10,000 ETH, against roughly 0.001 ETH for a legitimate 50-gwei transfer.

The ceilings are not the defect — the issue asked for a sanity bound and got one. The defect is structural: the thing being verified is not the thing the user approved.

Related, and worth resolving in the same change: txParams.from is never compared. expectedFrom is the current active address from getActiveAddress(), so an active-address switch between approval and signing produces a transaction from an account the approval did not name. Pre-existing, not introduced by #174.

Found by the independent review of #205.

Options

  • (a) Populate the transaction in the background, before the approval screen renders, and verify the signed artifact against that populated-and-displayed object. Every consequential field becomes exactly comparable and the ceilings become a backstop rather than the primary control.
  • (b) Have the popup return the displayed values alongside the signature and compare against those. Cheaper, but the popup is the component whose compromise this module exists to detect, so it is weaker.
  • (c) Keep the ceilings and accept that user-visible fee and nonce values are unverified.

Recommendation

(a). It is the only option where the verified object and the displayed object are the same object, which is the property the module is supposed to provide. (b) asks the untrusted side to report what it showed. (c) leaves a wallet able to sign a transaction whose fee the user never agreed to.

Definition of done

  • The object the user is shown is the object the signed transaction is verified against, for to, value, data, chainId, nonce, gas limit and all fee fields.
  • from is compared against the address named at approval time, not the current active address; an address switch mid-flow refuses rather than signs.
  • Ceilings remain as a backstop and are documented as such.
  • Tests cover an address switch between approval and signing, and a fee/nonce differing from the displayed value.
  • TODO.md updated in the same commit.
  • make check passes.
`verifySignedTx` compares the signed transaction against the **dApp's request object**. It never compares against the values the popup actually rendered to the user. For every field the dApp omitted — normally all of `nonce`, `gasLimit` and the fee fields, because `populateTransaction()` fills them in the popup — the number the user read on screen is verified by nothing. https://git.eeqj.de/sneak/AutistMask/issues/174 works around this with absolute ceilings instead of equality, which bounds the absurd but not the ruinous: a bare 21,000-gas transfer at the current `MAX_FEE_PER_GAS` ceiling hands the validator 2.1 ETH, and `MAX_GAS_LIMIT * MAX_FEE_PER_GAS` bounds the worst case at 10,000 ETH, against roughly 0.001 ETH for a legitimate 50-gwei transfer. The ceilings are not the defect — the issue asked for a sanity bound and got one. The defect is structural: the thing being verified is not the thing the user approved. Related, and worth resolving in the same change: `txParams.from` is never compared. `expectedFrom` is the **current** active address from `getActiveAddress()`, so an active-address switch between approval and signing produces a transaction from an account the approval did not name. Pre-existing, not introduced by https://git.eeqj.de/sneak/AutistMask/issues/174. Found by the independent review of https://git.eeqj.de/sneak/AutistMask/pulls/205. ## Options - **(a) Populate the transaction in the background**, before the approval screen renders, and verify the signed artifact against that populated-and-displayed object. Every consequential field becomes exactly comparable and the ceilings become a backstop rather than the primary control. - **(b) Have the popup return the displayed values alongside the signature** and compare against those. Cheaper, but the popup is the component whose compromise this module exists to detect, so it is weaker. - **(c) Keep the ceilings** and accept that user-visible fee and nonce values are unverified. ## Recommendation **(a).** It is the only option where the verified object and the displayed object are the same object, which is the property the module is supposed to provide. (b) asks the untrusted side to report what it showed. (c) leaves a wallet able to sign a transaction whose fee the user never agreed to. ## Definition of done - [ ] The object the user is shown is the object the signed transaction is verified against, for `to`, `value`, `data`, `chainId`, `nonce`, gas limit and all fee fields. - [ ] `from` is compared against the address named at approval time, not the current active address; an address switch mid-flow refuses rather than signs. - [ ] Ceilings remain as a backstop and are documented as such. - [ ] Tests cover an address switch between approval and signing, and a fee/nonce differing from the displayed value. - [ ] `TODO.md` updated in the same commit. - [ ] `make check` passes.
clawbot added this to the 1.0.0 milestone 2026-08-11 14:40:20 +02:00
Author
Collaborator

Plan, implementing option (a).

Where population goes. New src/shared/approvalTx.js: prepareApprovalTx(provider, from, txParams) runs VoidSigner(from, provider).populateTransaction() — the same sequence the popup ran — and serializes the result to a JSON-safe wire object carrying exactly the serialized fields of its type (SERIALIZED_FIELDS from approvalVerify.js), plus from. The background calls it in eth_sendTransaction before requestTxApproval(), so a pending approval only ever exists fully populated.

Failure behaviour. No approval record and no window are created until population succeeds. On failure — RPC down, estimate revert, a populated type outside ALLOWED_TX_TYPES, a fee past the ceilings, or a 20s timeout — eth_sendTransaction returns the error to the dApp and no window opens. The cost is that the wallet window appears only after the estimate returns and the user sees nothing in the wallet on failure; the gain is that the alternative (open first, populate behind a spinner) needs a half-initialised approval record, which is the state shape the settle interlock from #205 exists to protect. Today an estimate failure happens after the user has typed their password; this moves it earlier, not later.

from. The active address is captured as approval.approvedFrom when the approval is raised, and that — not getActiveAddress() — is the expectedFrom handed to verifySignedTx()/verifySignature(). The signing handler additionally refuses when the current active address is no longer approvedFrom (a refusal, so the approval is spent). A dApp-supplied txParams.from that is not the active address refuses the request up front. from also goes to the popup inside the approved object, so ethers' own transaction from address mismatch catches a switch popup-side too.

Verification. verifySignedTx compares the artifact against the populated-and-displayed object field by field over SERIALIZED_FIELDS[type] — nonce, gas limit and the fee fields become exact equality instead of "compare only if the dApp fixed it" — plus an exact type comparison. A quantity the approval does not carry is now a refusal, not a skip. assertNothingUnchecked, assertCanonicalBytes, assertNoForbiddenFields, the type allowlist and the ceilings all stay; the ceilings are re-documented as a backstop against a lying RPC (they now bound what the node can talk the wallet into displaying, not what the popup can sign).

Popup. No populateTransaction() and no provider at signing time — it signs the object it was given. The approval screen gains the fields it now vouches for: network, gas limit, max fee per gas, max total fee, nonce.

Tests. Address switch between approval and signing (both directions: popup signs as the new address; active address moved under an artifact signed as the approved one); a fee and a nonce differing from the displayed value; population unit tests over a stub provider. The duplicate-broadcast and settle-interlock tests keep asserting on the claim path's own message, so they still discriminate the interlock from a verification refusal.

Plan, implementing option (a). **Where population goes.** New `src/shared/approvalTx.js`: `prepareApprovalTx(provider, from, txParams)` runs `VoidSigner(from, provider).populateTransaction()` — the same sequence the popup ran — and serializes the result to a JSON-safe wire object carrying exactly the serialized fields of its type (`SERIALIZED_FIELDS` from `approvalVerify.js`), plus `from`. The background calls it in `eth_sendTransaction` **before** `requestTxApproval()`, so a pending approval only ever exists fully populated. **Failure behaviour.** No approval record and no window are created until population succeeds. On failure — RPC down, estimate revert, a populated type outside `ALLOWED_TX_TYPES`, a fee past the ceilings, or a 20s timeout — `eth_sendTransaction` returns the error to the dApp and no window opens. The cost is that the wallet window appears only after the estimate returns and the user sees nothing in the wallet on failure; the gain is that the alternative (open first, populate behind a spinner) needs a half-initialised approval record, which is the state shape the settle interlock from https://git.eeqj.de/sneak/AutistMask/pulls/205 exists to protect. Today an estimate failure happens *after* the user has typed their password; this moves it earlier, not later. **`from`.** The active address is captured as `approval.approvedFrom` when the approval is raised, and that — not `getActiveAddress()` — is the `expectedFrom` handed to `verifySignedTx()`/`verifySignature()`. The signing handler additionally refuses when the current active address is no longer `approvedFrom` (a refusal, so the approval is spent). A dApp-supplied `txParams.from` that is not the active address refuses the request up front. `from` also goes to the popup inside the approved object, so ethers' own `transaction from address mismatch` catches a switch popup-side too. **Verification.** `verifySignedTx` compares the artifact against the populated-and-displayed object field by field over `SERIALIZED_FIELDS[type]` — nonce, gas limit and the fee fields become exact equality instead of "compare only if the dApp fixed it" — plus an exact `type` comparison. A quantity the approval does not carry is now a refusal, not a skip. `assertNothingUnchecked`, `assertCanonicalBytes`, `assertNoForbiddenFields`, the type allowlist and the ceilings all stay; the ceilings are re-documented as a backstop against a lying RPC (they now bound what the node can talk the wallet into displaying, not what the popup can sign). **Popup.** No `populateTransaction()` and no provider at signing time — it signs the object it was given. The approval screen gains the fields it now vouches for: network, gas limit, max fee per gas, max total fee, nonce. **Tests.** Address switch between approval and signing (both directions: popup signs as the new address; active address moved under an artifact signed as the approved one); a fee and a nonce differing from the displayed value; population unit tests over a stub provider. The duplicate-broadcast and settle-interlock tests keep asserting on the claim path's own message, so they still discriminate the interlock from a verification refusal.
Author
Collaborator

Implemented in #269 (branch harden/issue-216-verify-displayed-values, base next). The PR body carries the full record; the definition of done, item by item:

  • Shown is verified. The background populates the transaction in src/shared/approvalTx.js before the approval window opens; that object is displayed, signed as given, and compared field by field over SERIALIZED_FIELDS[type] plus the type itself. nonce, gas limit and every fee field are now exact equality, and a quantity the approval does not fix is a refusal rather than a skipped comparison.
  • from at approval time. The approval pins approvedFrom; verification uses it instead of getActiveAddress(). A switch between approval and signing refuses (and spends the approval), including a switch during population, and on the message-signing path, which had the same defect. A request naming a non-active address is refused before any window opens.
  • Ceilings as a backstop. Kept and re-documented: equality with the screen cannot bound what the RPC node talks the wallet into displaying, so assertWithinCeilings() now also runs at population.
  • Tests. Address switch in both directions, a switch during population, fee and nonce differing from the displayed value at module and background-wiring level, plus the population module against a stub provider (ceilings, refused types, dropped page fields, timeout, JSON round trip).
  • TODO.md updated in the same commit.
  • make check green on the rebased branch: 26 suites, 613 tests, test-verify-build 18 cases, prettier --check clean. make build emits both bundles with DEBUG off.

Failure behaviour worth flagging for review: population failure raises no approval and opens no window — the error goes back to the requesting page, bounded by a 20-second timeout. Reasoning is in the PR body.

Implemented in https://git.eeqj.de/sneak/AutistMask/pulls/269 (branch `harden/issue-216-verify-displayed-values`, base `next`). The PR body carries the full record; the definition of done, item by item: - **Shown is verified.** The background populates the transaction in `src/shared/approvalTx.js` before the approval window opens; that object is displayed, signed as given, and compared field by field over `SERIALIZED_FIELDS[type]` plus the type itself. `nonce`, gas limit and every fee field are now exact equality, and a quantity the approval does not fix is a refusal rather than a skipped comparison. - **`from` at approval time.** The approval pins `approvedFrom`; verification uses it instead of `getActiveAddress()`. A switch between approval and signing refuses (and spends the approval), including a switch during population, and on the message-signing path, which had the same defect. A request naming a non-active address is refused before any window opens. - **Ceilings as a backstop.** Kept and re-documented: equality with the screen cannot bound what the RPC node talks the wallet into displaying, so `assertWithinCeilings()` now also runs at population. - **Tests.** Address switch in both directions, a switch during population, fee and nonce differing from the displayed value at module and background-wiring level, plus the population module against a stub provider (ceilings, refused types, dropped page fields, timeout, JSON round trip). - **`TODO.md`** updated in the same commit. - **`make check`** green on the rebased branch: 26 suites, 613 tests, `test-verify-build` 18 cases, `prettier --check` clean. `make build` emits both bundles with `DEBUG` off. Failure behaviour worth flagging for review: population failure raises no approval and opens no window — the error goes back to the requesting page, bounded by a 20-second timeout. Reasoning is in the PR body.
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: sneak/AutistMask#216