fix(near): sanitize untrusted strings on the intents render path - #459
Open
shahan-khatchadourian-anchorage wants to merge 2 commits into
Conversation
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
force-pushed
the
shahankhatchadourian/near-g-sanitize-intent-strings
branch
from
August 7, 2026 03:29
e8db95f to
11c0de9
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Stacked on #439.
Every caller-controlled string the intents renderer puts on the signing screen goes through
charset_safe. The core charset validator permits\nas 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 unfilteredmemoormsgrenders as extra apparent confirmed fields the parser never authored:The sibling borsh transaction path already filters at each insertion site.
charset_safemoves tofmt.rsso both NEAR paths share one definition rather than the intents path reaching across intoactions.rs.Sinks covered
memotoken_diff,transfer, and all three withdrawsmsgtransfer's notification, the withdraws,auth_calltoken_idnft_withdraw(non_fungible_token::TokenId=String)token_idsmt_withdraw(defuse_nep245::TokenId=String)unverified-token-metadatadiagnosticdiagnostic()itselfreferralis exempt and stays unfiltered: it is anAccountId, whose own charset rules already exclude everything the filter strips, so filtering it would be dead code.TokenIdis not exempt despite its account-id-shaped prefix — itsFromStrparses only the contract half as anAccountIdand takes the remainder verbatim into a plainString, whichDisplayround-trips. Verified against the pinneddefuse-corerev6dad94c.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 byasset_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, aserde_json::Errorthat interpolates the offending value with{}— so the choke point is what makes a newly added rule safe by construction.Behavior changes
mt_withdrawrenders itsmemo. It carries the field and silently dropped it, whileft_withdrawandnft_withdrawboth rendered theirs. Foldingmemointopush_withdraw_call_detailsalongsidemsgandstorage_depositcloses it by construction.Memoline. Applied at all three memo sites.Testing
Tests assert exact output rather than the absence of a newline, so a filter that dropped only
\nwhile passing\t,\r, control bytes or backslashes would still fail.charset_safegains direct coverage infmt.rsincluding the bidi-override and all-non-ASCII cases.192 tests pass with default features, 176 with
--no-default-features(theWarning-text build), clippy clean, fullmake testgreen.🤖 Generated with Claude Code