Skip to content

fix(near): sanitize untrusted strings on the intents render path - #459

Open
shahan-khatchadourian-anchorage wants to merge 2 commits into
shahankhatchadourian/near-f-diagnosticsfrom
shahankhatchadourian/near-g-sanitize-intent-strings
Open

fix(near): sanitize untrusted strings on the intents render path#459
shahan-khatchadourian-anchorage wants to merge 2 commits into
shahankhatchadourian/near-f-diagnosticsfrom
shahankhatchadourian/near-g-sanitize-intent-strings

Conversation

@shahan-khatchadourian-anchorage

Copy link
Copy Markdown
Contributor

Stacked on #439.

Every caller-controlled string the intents renderer puts on the signing screen goes through charset_safe. The core charset validator permits \n as the wallet's documented multi-line separator (validate_charset, and its own comment says attacker-controlled strings must be sanitized at the insertion site), so an unfiltered memo or msg renders as extra apparent confirmed fields the parser never authored:

   ├─ To: 608fe1e7...
   ├─ Amount: 1 (unresolved nep141:sol.omft.near)
   └─ Message: innocent
To: alice.near
Amount: 0.001 SOL

The sibling borsh transaction path already filters at each insertion site. charset_safe moves to fmt.rs so both NEAR paths share one definition rather than the intents path reaching across into actions.rs.

Sinks covered

Field Where
memo token_diff, transfer, and all three withdraws
msg transfer's notification, the withdraws, auth_call
token_id nft_withdraw (non_fungible_token::TokenId = String)
token_ids mt_withdraw (defuse_nep245::TokenId = String)
asset id the unresolved-amount fallback and the unverified-token-metadata diagnostic
every diagnostic message filtered inside diagnostic() itself

referral is exempt and stays unfiltered: it is an AccountId, whose own charset rules already exclude everything the filter strips, so filtering it would be dead code.

TokenId is not exempt despite its account-id-shaped prefix — its FromStr parses only the contract half as an AccountId and takes the remainder verbatim into a plain String, which Display round-trips. Verified against the pinned defuse-core rev 6dad94c.

Two details worth a reviewer's eye

Resolution runs on the raw asset id; only the rendered form is filtered. Filtering first would let a crafted id collapse onto a seeded one — nep141:wrap\u{7f}.near -> nep141:wrap.near — and borrow that token's symbol and decimals. Pinned by asset_id_resolves_on_its_raw_form_not_its_sanitized_form.

Filtering inside diagnostic() rather than at each call site. Every rule on this path quotes untrusted input — an asset id, a serde_json::Error that interpolates the offending value with {} — so the choke point is what makes a newly added rule safe by construction.

Behavior changes

  • mt_withdraw renders its memo. It carries the field and silently dropped it, while ft_withdraw and nft_withdraw both rendered theirs. Folding memo into push_withdraw_call_details alongside msg and storage_deposit closes it by construction.
  • Filtering happens before the emptiness test, so an all-non-ASCII memo drops out instead of rendering as a blank Memo line. Applied at all three memo sites.
  • Filtering strips rather than rejects: a legitimate memo with an accented character or emoji loses those characters instead of failing the whole parse. This matches the borsh path and keeps a non-ASCII memo from denying the signer their transaction.

Testing

Tests assert exact output rather than the absence of a newline, so a filter that dropped only \n while passing \t, \r, control bytes or backslashes would still fail. charset_safe gains direct coverage in fmt.rs including the bidi-override and all-non-ASCII cases.

192 tests pass with default features, 176 with --no-default-features (the Warning-text build), clippy clean, full make test green.

🤖 Generated with Claude Code

Every caller-controlled string the intents renderer put on the signing
screen reached `create_text_field` unfiltered. The core charset validator
permits `\n` as the wallet's documented multi-line separator, so a `memo`
or `msg` carrying newlines renders as extra apparent confirmed fields the
parser never authored:

    To: 608fe1e7...
    Amount: 1 (unresolved nep141:sol.omft.near)
    Message: innocent
    To: alice.near
    Amount: 0.001 SOL

The borsh transaction path already filters at each insertion site via
`charset_safe`; the intents path called it in exactly one place. Move the
helper to `fmt.rs` -- shared by both paths rather than reached across into
`actions.rs` -- and apply it to every untrusted sink: `memo` on
`token_diff`/`transfer`/the three withdraws, `msg` on `transfer`'s
notification and on the withdraws, and the NFT/MT token ids, which are
plain `String`s rather than `AccountId`s and so carry whatever bytes the
sender chose.

`referral` needs no filtering: it is an `AccountId`, whose own charset
rules already exclude everything `charset_safe` strips.

Fold `memo` into `push_withdraw_call_details` alongside `msg` and
`storage_deposit`, so all three token standards render the same trailing
fields. This also closes a silent drop: `mt_withdraw` carries a `memo` and
never rendered it, while `ft_withdraw` and `nft_withdraw` both did.

Filtering strips rather than rejects, so a legitimate memo carrying an
accented character or an emoji loses those characters instead of failing
the whole parse. That matches the borsh path and keeps a non-ASCII memo
from denying the signer their transaction.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Self-review turned up three attacker-controlled strings still reaching the
signing screen unfiltered, each reproducible through `parser_cli`:

- `auth_call`'s `msg` is a plain `String` handed to the callee's `on_auth`.
  The first pass filtered `msg` on `transfer`'s notification and on all
  three withdraws and skipped this fourth variant.

- An asset id echoed into the unresolved-amount fallback and into the
  `unverified-token-metadata` diagnostic. The reasoning that `AccountId`
  values are self-validating does not extend to `TokenId`: its `FromStr`
  parses only the contract half as an `AccountId` and takes the remainder
  verbatim into a plain `String`, which `Display` round-trips. Reachable
  from both `token_diff` diff keys and `transfer` tokens keys, and the
  spoofed text lands next to the number the signer is checking.

- The `extraction` diagnostic quotes a `serde_json::Error`, which
  interpolates an attacker-chosen `intent` tag with `{}`.

Filter inside `diagnostic()` rather than at each call site: every rule on
this path quotes untrusted input, so the choke point is what makes a newly
added rule safe by construction.

Resolution still runs against the raw asset id and only the rendered form
is filtered. Stripping first would let a crafted id collapse onto a seeded
one -- `nep141:wrap\u{7f}.near` -> `nep141:wrap.near` -- and borrow that
token's symbol and decimals.

Filter before testing for emptiness, so an all-non-ASCII memo drops out
instead of rendering as a blank `Memo` line, and apply that rule at all
three memo sites rather than only the withdraws'.

Tests: assert exact output rather than the absence of a newline, so a
filter that dropped only `\n` and passed `\t`, `\r`, control bytes or
backslashes would still fail; cover `charset_safe` directly in `fmt.rs`,
including the bidi-override and all-non-ASCII cases.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@shahan-khatchadourian-anchorage
shahan-khatchadourian-anchorage force-pushed the shahankhatchadourian/near-g-sanitize-intent-strings branch from e8db95f to 11c0de9 Compare August 7, 2026 03:29
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant