Skip to content

fix(xdr): bound decimal string length before BigInt parse in JSON decode - #1581

Merged
Ryang-21 merged 2 commits into
v17-feature-branchfrom
fix/bigint-digit-budget
Aug 4, 2026
Merged

fix(xdr): bound decimal string length before BigInt parse in JSON decode#1581
Ryang-21 merged 2 commits into
v17-feature-branchfrom
fix/bigint-digit-budget

Conversation

@Ryang-21

@Ryang-21 Ryang-21 commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

What

SEP-51 JSON decoding of integer fields now checks the decimal string's length before handing it to BigInt(). A string longer than the target width can possibly hold is rejected up front instead of being parsed and then found out of range.

The new assertDecimalDigitBudget(s, bits, name) in src/xdr/values/bigint-parts.ts derives the budget from the width: a bits-wide integer needs at most ceil(bits * log10(2)) decimal digits, plus 2 characters of slack for an optional leading - and a digit of rounding headroom. That gives 22 characters for 64 bits, 41 for 128, and 80 for 256. It is wired into the five decode paths in src/xdr/values/to-json.ts: the int64/uint64 case in walkFromJson, and the fromJson overrides for Int128Parts, Uint128Parts, Int256Parts, and Uint256Parts.

The check is deliberately scoped to the decode paths, where the SDK is the parser and the string length is chosen by whoever wrote the JSON rather than by the caller. Construction entry points that take a developer-supplied value — the Int128/Uint128/Int256/Uint256 constructors, xdr.Int64/xdr.Uint64, ScInt, and XdrLargeInt — are left as they are: those callers pick the argument, so validating it belongs at their own trust boundary, not inside the SDK.

One behavior change worth knowing about: non-canonical spellings that native BigInt() tolerates — long runs of leading zeros, a leading +, surrounding whitespace, or 0x/0o/0b prefixes — are now rejected once they push the string past the budget, so "0".repeat(10000) + "1" throws where it previously decoded as 1. SEP-51 integer fields carry canonical decimal strings, so no legitimate input is affected. Every extreme in-range value is covered by a test, including unsigned max and signed min/max for all four widths.

No CHANGELOG entry: these decode paths are unreleased, so there is no shipped behavior for this to change.

Why

Parsing an attacker-supplied multi-megabyte decimal string costs work proportional to the input, and produces a value that the range check is then certain to reject. Measured on Node 22, base-10 BigInt parsing runs at roughly 80 ns per input byte — 44 ms for 1M digits, 230 ms for 4M, 1.26 s for 16M. That is linear-ish rather than quadratic, so this is a modest cost rather than a severe one, but it is entirely wasted: the length alone already proves the value cannot fit. Checking it first makes the rejection O(1) and keeps a megabyte-long value out of the interpolated range error message.

Memo.fromJson({ id }) is the clearest instance — an application decoding a JSON payload from a third party reaches BigInt() on an unbounded string with no opportunity to bound it first.

Test plan

test/unit/xdr/xdr_json_bigint_digit_budget.test.ts covers the budget formula per width, the boundary at budget and budget + 1, every decode path against a 1M-digit string, the non-canonical padding case, and the extreme in-range values for all four widths. The regression guard asserts the budget error message and asserts the out of range message is not thrown, which is what distinguishes check-then-parse from parse-then-check; disabling the check fails it. The to_json and bigint_parts suites pass unchanged.

@github-project-automation github-project-automation Bot moved this to Backlog (Not Ready) in DevX Aug 3, 2026
@Ryang-21
Ryang-21 requested a review from Copilot August 3, 2026 19:53

Copilot AI 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.

Pull request overview

Bounds SEP-51 integer JSON strings before BigInt() parsing to avoid unnecessary work on oversized input.

Changes:

  • Adds a width-based decimal-string budget validator.
  • Applies validation to 64-, 128-, and 256-bit JSON decoders.
  • Adds regression and boundary coverage.

Reviewed changes

Copilot reviewed 3 out of 3 changed files in this pull request and generated no comments.

File Description
src/xdr/values/bigint-parts.ts Adds digit-budget validation.
src/xdr/values/to-json.ts Validates integer strings before parsing.
test/unit/xdr/xdr_json_bigint_digit_budget.test.ts Tests oversized inputs and valid extremes.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

@Ryang-21
Ryang-21 requested a review from quietbits August 3, 2026 19:57
Comment thread src/xdr/values/bigint-parts.ts Outdated
bits: number,
name: string,
): void {
const maxDigits = Math.ceil(bits * 0.30103) + 2;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Should we put 0.30103 and 2 in a const to make it more clear what it is just by looking at the function?

@Ryang-21
Ryang-21 merged commit 5a6f76f into v17-feature-branch Aug 4, 2026
13 checks passed
@github-project-automation github-project-automation Bot moved this from Backlog (Not Ready) to Done in DevX Aug 4, 2026
@Ryang-21
Ryang-21 deleted the fix/bigint-digit-budget branch August 4, 2026 17:15
Ryang-21 added a commit that referenced this pull request Aug 10, 2026
* Class XDR Implementation (#1422)

* feat(xdr): codegen tool + schema source

* feat(xdr): add class-based XDR runtime and sep51 JSON walker

* refactor(numbers): drop LargeInt classes, delegate to new XDR layer

* refactor(base): migrate src/base to new XDR layer; drop legacy xdr.ts + generated

* refactor: migrate downstream consumers (bindings/contract/horizon/rpc/webauth)

* feat(base/scval): add bool to ScValType

* allow opaque xdr types to be initalized via string

* refactor xdr strings to be represented soley via bytes with a dx friendly XdrString wrapper class

* generate a value getter function for void union cases

* add a is() function to the generated XDR union classes for instanceOf checks

* feat(xdr): regenerate schemas against @stellar/js-xdr and add CAP-71 credentials

* feat(xdr): wire the toJSON hook so JSON.stringify emits SEP-0051

* fix(bindings): emit Uint8Array for bytes/bytesN to match scValToNative

* refactor(contract): rename fromJSON to fromJson with deprecated aliases

* feat(xdr): accept ASCII asset codes with zero padding in constructors

* Migrate public API from Buffer to Uint8Array (#1564)

* feat(base): migrate crypto and strkey APIs to Uint8Array

* feat(base)!: migrate value types to Uint8Array

* feat(base)!: migrate transactions, operations, and auth to Uint8Array

* feat!: migrate contract, rpc, and webauth layers to Uint8Array

* build!: drop buffer polyfill and dependency

* fix(horizon): type manage_data value as string to match runtime API

* fix(xdr): emit SEP-51 key `type` instead of Rust-escaped `type_` (#1571)

* build(xdr): regenerate xdr.json via docker from pinned stellar-xdr commit (#1575)

* build(xdr): regenerate schema from stellar-xdr with CAP-83 and CAP-85 ungated (#1576)

* build(xdr): regenerate schema from stellar-xdr with CAP-83 and CAP-85 ungated

* fix(xdr): keep consumers compiling against the regenerated union arms

* fix(vitest): isolate browser dep cache per transport

* feat(xdr): support CAP-83 empty tx set values and CAP-85 external executables (#1577)

* build(xdr): fail the schema download instead of masking it in a pipe

* feat(xdr): support CAP-83 and CAP-85 protocol values

* fix(xdr): bound decimal string length before BigInt parse in JSON decode (#1581)

* fix(xdr): bound decimal string length before BigInt parse in json decode

* refactor(xdr): name the digit-budget constants in bigint-parts

* fix(xdr): restrict fromJson to SEP-0051 keys and reject unknown fields (#1582)

* fix(xdr): restrict fromJson to SEP-51 keys and reject unknown fields

* fix(test): correct horizon corpus fixture path so corpus tests run

* fix(strkey): bound decodeCheck input length before base32 decode (#1583)

* fix(xdr): reject AssetCode12 JSON codes shorter than 5 bytes (#1585)

* fix(horizon)!: make TransactionFailedExtras result_codes.operations optional (#1586)

* Fix: signed payload strkey framing (#1588)

* fix(strkey): validate signed payload framing in decodeCheck

* Fix: xdr json decode validation (#1592)

* fix(xdr): throw on unknown union discriminant in fromXdrObject

* fix(strkey): validate the claimable balance discriminant byte

* fix(xdr): reject non-decimal integer strings in JSON decoding

* V17.0.0 rc.1 (#1593)

* chore(release): cut v17.0.0-rc.1

* fix(spec): restore instanceof Map check lost in the v17 merge

* feat(xdr): add validateXdr static to every generated type (#1597)

* feat(xdr): add validateXdr static to every generated type

* fix(contract): declare error classes, make types self-contained (#1627)

* perf(strkey): reject by length and prefix before decodeCheck throws (#1629)

* fix: restore wide-int bounds statics, document Memo.text break (#1628)

* fix(xdr): restore wide-int MIN_VALUE/MAX_VALUE statics

---------

Co-authored-by: Iveta <[email protected]>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: Done

Development

Successfully merging this pull request may close these issues.

3 participants