Skip to content

fix(codecs-numbers): reject truncated and overlong shortU16 encodings - #2067

Merged
lorisleiva merged 7 commits into
anza-xyz:mainfrom
latent-9:fix-shortu16-decoder-bounds
Sep 22, 2026
Merged

lorisleiva merged 7 commits into
anza-xyz:mainfrom
latent-9:fix-shortu16-decoder-bounds

Conversation

@latent-9

Copy link
Copy Markdown

Supersedes #1986, which the stale bot closed during review limbo — reopening it is permission-blocked (ReopenPullRequest: FORBIDDEN), so re-submitting the identical 2 commits (bc820fd, a3e06ae) with the verification below.

Problem

The shortU16 decoder loop had no upper bound, violating the codec's own documented contract of a 1 to 3 byte encoding (maxSize: 3). Two concrete failure modes:

  1. Overlong chains accepted: inputs with continuation bits keep decoding, producing values far outside the 0..65535 domain.
  2. Truncated input silently misread: past the end of the buffer, bytes[offset] is undefined; undefined & 0b10000000 === 0 terminates the loop, so truncated input decoded to garbage with an offset beyond bytes.length (e.g. decode([0x80]) → [0, 2]).

Change

Bound the read loop at three bytes with per-byte availability asserts (INVALID_BYTE_LENGTH, like every other number decoder); a continuation bit after three bytes throws NUMBER_OUT_OF_RANGE.

Verification (executed, 10/10)

Ran the actual pre/post decoder code with the real @solana/codecs-core + @solana/errors packages:

  • Old: [0x80]→[0,2], [0x80,0x80] silent, [0xff,0xff,0xff,0x00]→2097151
  • New: exact claimed codes + contexts; valid decodes unchanged; full 0..65535 round-trip sweep intact.

latent-9 added 2 commits August 26, 2026 23:53
The shortU16 decoder loop had no upper bound, so a continuation chain
longer than the documented three-byte encoding was accepted, producing
values far outside the u16 domain the encoder enforces. Worse, once the
chain ran past the end of the buffer, the out-of-bounds byte read as
undefined terminated the loop silently: truncated input decoded to
garbage and returned an offset beyond bytes.length instead of failing.

Bound the loop at three bytes and assert per-byte availability through
assertByteArrayHasEnoughBytesForCodec, matching every other number
decoder. A chain that still continues after three bytes now throws
NUMBER_OUT_OF_RANGE, mirroring the encoder's own range assertion.
@lorisleiva

Copy link
Copy Markdown
Member

@trevor-cortex

@bundlemon

bundlemon Bot commented Sep 17, 2026 •

Copy link
Copy Markdown

BundleMon

Files updated (4)
Status Path Size Limits
✅ codecs-numbers/dist/index.browser.mjs
2.28KB (+106B +4.75%) -
✅ codecs-numbers/dist/index.native.mjs
2.28KB (+106B +4.75%) -
✅ codecs-numbers/dist/index.node.mjs
2.28KB (+106B +4.76%) -
✅ @solana/kit production bundle
kit/dist/index.production.min.js
57.79KB (+45B +0.08%) -
Unchanged files (146)
Status Path Size Limits
✅ errors/dist/index.node.mjs
22.27KB -
✅ errors/dist/index.browser.mjs
22.24KB -
✅ errors/dist/index.native.mjs
22.24KB -
✅ wallet-account-signer/dist/index.node.mjs
18.9KB -
✅ wallet-account-signer/dist/index.native.mjs
18.87KB -
✅ wallet-account-signer/dist/index.browser.mjs
18.87KB -
✅ rpc-graphql/dist/index.browser.mjs
18.87KB -
✅ rpc-graphql/dist/index.native.mjs
18.87KB -
✅ rpc-graphql/dist/index.node.mjs
18.86KB -
✅ transaction-messages/dist/index.browser.mjs
11.84KB -
✅ transaction-messages/dist/index.native.mjs
11.83KB -
✅ transaction-messages/dist/index.node.mjs
11.83KB -
✅ instruction-plans/dist/index.browser.mjs
7.31KB -
✅ instruction-plans/dist/index.native.mjs
7.3KB -
✅ instruction-plans/dist/index.node.mjs
7.3KB -
✅ codecs-data-structures/dist/index.browser.mjs
5.63KB -
✅ codecs-data-structures/dist/index.native.mjs
5.62KB -
✅ codecs-data-structures/dist/index.node.mjs
5.62KB -
✅ react/dist/index.browser.mjs
5.32KB -
✅ react/dist/index.native.mjs
5.32KB -
✅ react/dist/index.node.mjs
5.32KB -
✅ offchain-messages/dist/index.browser.mjs
5.25KB -
✅ offchain-messages/dist/index.native.mjs
5.24KB -
✅ offchain-messages/dist/index.node.mjs
5.24KB -
✅ fixed-points/dist/index.browser.mjs
5.08KB -
✅ fixed-points/dist/index.native.mjs
5.07KB -
✅ fixed-points/dist/index.node.mjs
5.07KB -
✅ kit/dist/index.browser.mjs
4.69KB -
✅ kit/dist/index.native.mjs
4.69KB -
✅ kit/dist/index.node.mjs
4.69KB -
✅ transactions/dist/index.browser.mjs
4.05KB -
✅ transactions/dist/index.native.mjs
4.05KB -
✅ transactions/dist/index.node.mjs
4.05KB -
✅ codecs-core/dist/index.browser.mjs
3.81KB -
✅ codecs-core/dist/index.native.mjs
3.81KB -
✅ codecs-core/dist/index.node.mjs
3.81KB -
✅ webcrypto-ed25519-polyfill/dist/index.node.mj
s
3.61KB -
✅ webcrypto-ed25519-polyfill/dist/index.browser
.mjs
3.59KB -
✅ webcrypto-ed25519-polyfill/dist/index.native.
mjs
3.57KB -
✅ signers/dist/index.browser.mjs
3.5KB -
✅ signers/dist/index.native.mjs
3.5KB -
✅ signers/dist/index.node.mjs
3.5KB -
✅ rpc-subscriptions/dist/index.browser.mjs
3.37KB -
✅ rpc-subscriptions/dist/index.node.mjs
3.34KB -
✅ rpc-subscriptions/dist/index.native.mjs
3.31KB -
✅ codecs-strings/dist/index.browser.mjs
3.29KB -
✅ codecs-strings/dist/index.node.mjs
3.17KB -
✅ codecs-strings/dist/index.native.mjs
3.13KB -
✅ subscribable/dist/index.node.mjs
3.13KB -
✅ rpc-transformers/dist/index.browser.mjs
3.1KB -
✅ rpc-transformers/dist/index.native.mjs
3.1KB -
✅ rpc-transformers/dist/index.node.mjs
3.1KB -
✅ keys/dist/index.node.mjs
3.06KB -
✅ subscribable/dist/index.native.mjs
3.06KB -
✅ subscribable/dist/index.browser.mjs
3.05KB -
✅ addresses/dist/index.browser.mjs
2.93KB -
✅ addresses/dist/index.native.mjs
2.92KB -
✅ addresses/dist/index.node.mjs
2.92KB -
✅ keys/dist/index.browser.mjs
2.85KB -
✅ keys/dist/index.native.mjs
2.85KB -
✅ transaction-introspection/dist/index.browser.
mjs
2.73KB -
✅ transaction-introspection/dist/index.native.m
js
2.73KB -
✅ transaction-introspection/dist/index.node.mjs
2.73KB -
✅ transaction-confirmation/dist/index.node.mjs
2.42KB -
✅ transaction-confirmation/dist/index.native.mj
s
2.37KB -
✅ sysvars/dist/index.browser.mjs
2.37KB -
✅ sysvars/dist/index.native.mjs
2.37KB -
✅ sysvars/dist/index.node.mjs
2.37KB -
✅ transaction-confirmation/dist/index.browser.m
js
2.36KB -
✅ rpc-subscriptions-spec/dist/index.node.mjs
2.33KB -
✅ rpc-subscriptions-spec/dist/index.native.mjs
2.29KB -
✅ rpc-subscriptions-spec/dist/index.browser.mjs
2.29KB -
✅ rpc/dist/index.node.mjs
1.94KB -
✅ rpc-transport-http/dist/index.browser.mjs
1.9KB -
✅ rpc-types/dist/index.browser.mjs
1.9KB -
✅ rpc-types/dist/index.native.mjs
1.9KB -
✅ rpc-types/dist/index.node.mjs
1.9KB -
✅ rpc-transport-http/dist/index.native.mjs
1.9KB -
✅ rpc/dist/index.native.mjs
1.8KB -
✅ rpc/dist/index.browser.mjs
1.79KB -
✅ rpc-transport-http/dist/index.node.mjs
1.71KB -
✅ rpc-spec-types/dist/index.browser.mjs
1.54KB -
✅ rpc-spec-types/dist/index.native.mjs
1.54KB -
✅ rpc-spec-types/dist/index.node.mjs
1.54KB -
✅ program-client-core/dist/index.browser.mjs
1.39KB -
✅ program-client-core/dist/index.native.mjs
1.39KB -
✅ program-client-core/dist/index.node.mjs
1.39KB -
✅ rpc-subscriptions-channel-websocket/dist/inde
x.node.mjs
1.33KB -
✅ rpc-subscriptions-channel-websocket/dist/inde
x.native.mjs
1.27KB -
✅ rpc-subscriptions-channel-websocket/dist/inde
x.browser.mjs
1.26KB -
✅ plugin-core/dist/index.browser.mjs
1.18KB -
✅ plugin-core/dist/index.native.mjs
1.18KB -
✅ plugin-core/dist/index.node.mjs
1.18KB -
✅ options/dist/index.browser.mjs
1.18KB -
✅ options/dist/index.native.mjs
1.18KB -
✅ options/dist/index.node.mjs
1.17KB -
✅ accounts/dist/index.browser.mjs
1.17KB -
✅ accounts/dist/index.native.mjs
1.17KB -
✅ accounts/dist/index.node.mjs
1.16KB -
✅ rpc-api/dist/index.browser.mjs
1.08KB -
✅ rpc-api/dist/index.native.mjs
1.08KB -
✅ rpc-api/dist/index.node.mjs
1.08KB -
✅ compat/dist/index.browser.mjs
969B -
✅ compat/dist/index.native.mjs
968B -
✅ compat/dist/index.node.mjs
966B -
✅ rpc-spec/dist/index.browser.mjs
928B -
✅ rpc-spec/dist/index.native.mjs
928B -
✅ rpc-spec/dist/index.node.mjs
926B -
✅ promises/dist/index.native.mjs
841B -
✅ promises/dist/index.node.mjs
840B -
✅ promises/dist/index.browser.mjs
839B -
✅ rpc-subscriptions-api/dist/index.browser.mjs
810B -
✅ rpc-subscriptions-api/dist/index.native.mjs
808B -
✅ rpc-subscriptions-api/dist/index.node.mjs
807B -
✅ assertions/dist/index.browser.mjs
783B -
✅ instructions/dist/index.browser.mjs
771B -
✅ instructions/dist/index.native.mjs
770B -
✅ instructions/dist/index.node.mjs
768B -
✅ fast-stable-stringify/dist/index.browser.mjs
726B -
✅ fast-stable-stringify/dist/index.native.mjs
725B -
✅ assertions/dist/index.native.mjs
724B -
✅ fast-stable-stringify/dist/index.node.mjs
724B -
✅ assertions/dist/index.node.mjs
723B -
✅ programs/dist/index.browser.mjs
329B -
✅ programs/dist/index.native.mjs
327B -
✅ programs/dist/index.node.mjs
325B -
✅ fs-impl/dist/index.browser.mjs
245B -
✅ event-target-impl/dist/index.node.mjs
230B -
✅ functional/dist/index.browser.mjs
154B -
✅ functional/dist/index.native.mjs
152B -
✅ text-encoding-impl/dist/index.native.mjs
152B -
✅ functional/dist/index.node.mjs
151B -
✅ codecs/dist/index.browser.mjs
145B -
✅ codecs/dist/index.native.mjs
144B -
✅ codecs/dist/index.node.mjs
142B -
✅ event-target-impl/dist/index.browser.mjs
133B -
✅ ws-impl/dist/index.node.mjs
131B -
✅ text-encoding-impl/dist/index.browser.mjs
122B -
✅ fs-impl/dist/index.node.mjs
120B -
✅ text-encoding-impl/dist/index.node.mjs
119B -
✅ ws-impl/dist/index.browser.mjs
113B -
✅ crypto-impl/dist/index.node.mjs
111B -
✅ crypto-impl/dist/index.browser.mjs
109B -
✅ rpc-parsed-types/dist/index.browser.mjs
66B -
✅ rpc-parsed-types/dist/index.native.mjs
65B -
✅ rpc-parsed-types/dist/index.node.mjs
63B -

Total files change +363B +0.06%

Final result: ✅

View report in BundleMon website ➡️


Current branch size history | Target branch size history

@trevor-cortex trevor-cortex 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.

Summary

Bounds the shortU16 decoder's read loop at three bytes. Each iteration now asserts the byte is actually present (INVALID_BYTE_LENGTH, same helper the fixed-size number decoders use), and a continuation bit on the third byte throws NUMBER_OUT_OF_RANGE. Two tests cover truncation at 1 and 2 bytes and the 3-continuation-bytes case; patch changeset for @solana/codecs-numbers.

The core change is correct and I traced the loop by hand: valid 1/2/3-byte encodings return at the same offsets as before, [0x80] and [0x80, 0x80] now hit the length assert with the exact contexts the tests claim, and [0xff, 0xff, 0xff] (with or without a trailing byte) reaches the throw. The existing 0..65535 round-trip sweep guards against regressions on the happy path. Nice that the overlong check doesn't depend on a fourth byte existing.

Things to watch

The u16 bound is only half-enforced. The new error says the value must be in 0..65535, but a 3-byte encoding whose third byte has no continuation bit is still returned unchecked: [0x80, 0x80, 0x04] decodes to 65536 and [0xff, 0xff, 0x7f] to 2097151. Agave's reference decoder (short_vec.rs) has a distinct Overflow error for exactly this case alongside ByteThreeContinues. Since assertNumberIsBetweenForCodec is already imported in this file, closing the gap is a one-liner before the return — see inline. I'd fold it into this PR so the decoder's contract matches the error it now throws, rather than leaving a second "shortU16 accepts out-of-range values" fix for later.

Import grouping — a blank line was introduced between the two @solana/* imports in short-u16.ts. Elsewhere in the repo (e.g. codecs-data-structures/src/array.ts) package imports sit in one group with no blank line, which is what simple-import-sort enforces, so test:lint will likely flag it. pnpm style:fix should sort it out.

Notes for other reviewers / possible follow-ups (non-blocking)

  • The getShortU16Codec docblock (unchanged, ~L130–140) still describes the third byte as using "all 8 bits" and lists a 3-byte range of 16,384 to 4,194,303. That was already inaccurate (the implementation masks every byte to 7 bits, and the encoder rejects anything above 65535), but this PR is explicitly about tightening the decoder's contract, so it's a natural moment to correct that comment to 16,384 to 65,535 and drop the "all 8 bits" line. Adding @throws entries to the decoder docblock would also fit the repo's docblock guidance.
  • Agave additionally rejects non-canonical "aliased" encodings (a 0x00 byte anywhere after the first, e.g. [0x80, 0x00] for 0). Kit still accepts those. Probably fine to leave — it's a stricter canonicality rule rather than a domain violation — but worth knowing if anyone wants byte-for-byte parity with the validator later.
  • Behavioural note for the changelog: inputs that previously decoded (to garbage) now throw. Patch is the right bump for a fix; just flagging that any consumer relying on lenient decoding of malformed data will start seeing errors.

…t imports

Address review: a terminated 3-byte encoding can still exceed the u16
domain ([0x80,0x80,0x04] is 65536), so assert the decoded value with
assertNumberIsBetweenForCodec before returning, mirroring the encoder.
Also fix simple-import-sort grouping and correct the docblock's third
byte description (low 7 bits, max 65535).
@changeset-bot

changeset-bot Bot commented Sep 17, 2026 •

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: fa6e525

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 48 packages
Name Type
@solana/codecs-numbers Patch
@solana/codecs-data-structures Patch
@solana/codecs-strings Patch
@solana/codecs Patch
@solana/offchain-messages Patch
@solana/options Patch
@solana/program-client-core Patch
@solana/rpc-types Patch
@solana/sysvars Patch
@solana/transaction-messages Patch
@solana/transactions Patch
@solana/accounts Patch
@solana/addresses Patch
@solana/keys Patch
@solana/rpc-api Patch
@solana/rpc-graphql Patch
@solana/transaction-confirmation Patch
@solana/transaction-introspection Patch
@solana/instruction-plans Patch
@solana/kit Patch
@solana/signers Patch
@solana/plugin-interfaces Patch
@solana/rpc-parsed-types Patch
@solana/rpc-subscriptions-api Patch
@solana/rpc-subscriptions Patch
@solana/rpc-transformers Patch
@solana/rpc Patch
@solana/programs Patch
@solana/react Patch
@solana/wallet-account-signer Patch
@solana/compat Patch
@solana/instructions Patch
@solana/assertions Patch
@solana/codecs-core Patch
@solana/errors Patch
@solana/fast-stable-stringify Patch
@solana/fixed-points Patch
@solana/functional Patch
@solana/nominal-types Patch
@solana/plugin-core Patch
@solana/promises Patch
@solana/rpc-spec-types Patch
@solana/rpc-spec Patch
@solana/rpc-subscriptions-channel-websocket Patch
@solana/rpc-subscriptions-spec Patch
@solana/rpc-transport-http Patch
@solana/subscribable Patch
@solana/webcrypto-ed25519-polyfill Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@latent-9

Copy link
Copy Markdown
Author

@trevor-cortex addressed, thanks for the sharp trace:

  • Range gap closed: assertNumberIsBetweenForCodec('shortU16', 0, 65535, value) before the in-loop return, mirroring the encoder. Verified by execution: [0x80,0x80,0x04]→NUMBER_OUT_OF_RANGE {value: 65536}, [0xff,0xff,0x7f]→{value: 2097151}, while max-valid [0xff,0xff,0x03]→65535 and the full 0..65535 round-trip sweep stay green, plus the three previous regression cases unchanged.
  • Import grouping fixed (single @solana/* group) — that was the exact lint failure in CI.
  • Docblock corrected (low 7 bits, 16,384–65,535) + regression test for the 65536 case.

Left the aliased-encoding leniency as-is per your non-blocking note.

Fully enumerate the terminated 1/2/3-byte spaces (accept iff value in
0..65535), the complete first-overflow row, and 5000 seeded arbitrary
inputs asserting in-range values, in-bounds offsets, and re-encode
round-trips.
@koriyoshi2041

Copy link
Copy Markdown
Contributor

The current bf1e1309 head is still red in both Node jobs on @solana/codecs-numbers#test:lint:

  • short-u16.ts:1: simple-import-sort/imports
  • short-u16-test.ts:123: jest/no-conditional-in-test
  • short-u16-test.ts:124,126,198: jest/no-conditional-expect

The production behavior and exhaustive coverage look useful; this appears limited to reshaping the test assertions and applying the repository's import sort before rerunning CI.

Fixes the two test:lint failures blocking CI on anza-xyz#2067: adds the missing blank line between the external and relative import groups in short-u16.ts per simple-import-sort, and rewrites the two new short-u16 tests to avoid jest/no-conditional-in-test and jest/no-conditional-expect by splitting the 1-byte enumeration loop and collecting fuzz outcomes before asserting. Test coverage is unchanged — the same seeded inputs run through the same assertions. Verified with compile, lint, typecheck, unit tests on Node and browser, and prettier, all green.
Restores the shortU16 test file to 74c71f1, removing the exhaustive 1/2/3-byte enumeration and fuzz tests that duplicated the full-domain round-trip sweep, per review feedback. The targeted truncation, overlong-chain, and u16-overflow boundary tests stay, as does the import-sort fix in short-u16.ts.
@latent-9

Copy link
Copy Markdown
Author

@lorisleiva You're right — the sweep in the first test already covers those values. Dropped the exhaustive enumeration tests; kept the targeted boundary pins and the import-sort fix.

@koriyoshi2041

Copy link
Copy Markdown
Contributor

Verified the updated bad233a5 head: both Node build/test jobs and the aggregate all-web3-checks gate are green now. The targeted 65,536 boundary pin remains, while the lint-hostile exhaustive test was removed and the import order is fixed. Thanks for closing this out.

…chains

The decoder only reaches the throw when all three bytes have their
continuation bit set, so the input demands a fourth byte. That is a
byte-length problem rather than a range problem, and the accumulated
value in the error context was misleading anyway. Replaces the magic
number with a local constant and trims the explanatory comments.
@latent-9

Copy link
Copy Markdown
Author

Thanks for the review! All three points are addressed in fa6e525: the overlong-chain case now throws SOLANA_ERROR__CODECS__INVALID_BYTE_LENGTH (the chain demands a fourth byte, so it is a byte-length problem, and the accumulated value was indeed misleading in the error context), the explanatory comments are gone, and the magic number is replaced with a local MAX_SHORT_U16_VALUE constant.

One note: the bytesLength: 4 in the error context is the length the encoding demands, not the length of the input — [0xff, 0xff, 0xff] (exactly three bytes) hits the same throw. Happy to adjust if you'd rather it read differently.

@lorisleiva lorisleiva left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Thanks!

@lorisleiva
lorisleiva added this pull request to the merge queue Sep 22, 2026
Merged via the queue into anza-xyz:main with commit 56b4960 Sep 22, 2026
14 checks passed
@github-actions

Copy link
Copy Markdown
Contributor

🔎💬 Inkeep AI search and chat service is syncing content for source 'Solana Kit Docs'

This was referenced Sep 22, 2026
@github-actions

github-actions Bot commented Oct 7, 2026

Copy link
Copy Markdown
Contributor

Because there has been no activity on this PR for 14 days since it was merged, it has been automatically locked. Please open a new issue if it requires a follow up.

@github-actions github-actions Bot locked as resolved and limited conversation to collaborators Oct 7, 2026
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants