Skip to content

fix: reject 5-byte LEB128 encodings that overflow uint32 in decodeUInt32 - #1690

Merged
gjtorikian merged 2 commits into
workos:mainfrom
eeshsaxena:fix/leb128-decode-uint32-range
Sep 17, 2026
Merged

gjtorikian merged 2 commits into
workos:mainfrom
eeshsaxena:fix/leb128-decode-uint32-range

Conversation

@eeshsaxena

Copy link
Copy Markdown
Contributor

Problem

decodeUInt32 in src/common/utils/leb128.ts validates the byte count of a LEB128 sequence but not the range of its last byte.

A uint32 needs at most 5 bytes. The 5th byte is read at shift = 28, and only 32 - 28 = 4 of its 7 data bits fit in a uint32. When the 5th byte carries data above 0x0F, the value is greater than MAX_UINT32, but the JavaScript 32-bit << silently drops the overflowing bits instead of erroring:

result |= (byte & DATA_BITS_MASK) << shift; // (0x10 << 28) truncates to 0

So over-range 5-byte encodings decode to a wrong value rather than being rejected:

input decoded value correct behavior
[0x80,0x80,0x80,0x80,0x10] (2³²) 0 reject
[0xFF,0xFF,0xFF,0xFF,0x1F] 4294967295 reject
[0xFF,0xFF,0xFF,0xFF,0x7F] 4294967295 reject

encodeUInt32 already validates the uint32 range, and the suite's existing throws for encoding that exceeds uint32 range test only covers the too-many-bytes case ([0x80,0x80,0x80,0x80,0x80,0x01]), so this class of over-range input slipped through — a decoder that's meant to fail on out-of-range input silently returns garbage instead.

Fix

Reject an over-range final byte before the shift, so the decoder fails loudly to match the encoder:

if (shift === FINAL_BYTE_SHIFT && (byte & DATA_BITS_MASK) > FINAL_BYTE_MAX_DATA) {
  throw new Error('LEB128 sequence exceeds uint32 range');
}

FINAL_BYTE_SHIFT is (MAX_BYTES_FOR_UINT32 - 1) * DATA_BITS_PER_BYTE = 28 and FINAL_BYTE_MAX_DATA = 0x0F. Every valid encoding (final byte ≤ 0x0F, including MAX_UINT32[0xFF,0xFF,0xFF,0xFF,0x0F]) is unaffected; only genuinely out-of-range input now throws.

Tests

Added three cases to the invalid inputs block: two over-range 5-byte sequences that must throw LEB128 sequence exceeds uint32 range, and one asserting MAX_UINT32 (final byte exactly 0x0F) still decodes to 4294967295. Full leb128.spec.ts passes (42/42); reverting only the source change fails the two new "throws" cases, confirming they catch the bug.

decodeUInt32 guards the maximum byte count, but not the range of the final
byte. On the 5th byte only 4 of its 7 data bits fit in a uint32, so a byte
above 0x0F encodes a value greater than MAX_UINT32. The 32-bit `<<` then
silently drops the overflowing bits, so e.g. [0x80,0x80,0x80,0x80,0x10]
(2^32) decodes to 0 and [0xFF,0xFF,0xFF,0xFF,0x7F] decodes to 4294967295
instead of being rejected.

encodeUInt32 already validates the uint32 range, and the suite's existing
'exceeds uint32 range' test only covered the too-many-bytes case. Reject an
over-range final byte before the shift so decode fails loudly on such input,
matching the encoder. Valid encodings (final byte <= 0x0F, including
MAX_UINT32) are unaffected.
@eeshsaxena
eeshsaxena requested review from a team as code owners September 1, 2026 17:50
@greptile-apps

greptile-apps Bot commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

RetriggerConfidence Score: 5/5

The PR appears safe to merge.

Summary

The PR strengthens unsigned LEB128 decoding by rejecting five-byte encodings whose final payload bits exceed the uint32 range.

  • Adds a final-byte range check before JavaScript’s truncating 32-bit shift.
  • Adds unit coverage for overflowing encodings and the valid MAX_UINT32 boundary.
  • Adds Vault-level coverage confirming malformed key-length prefixes are rejected during decryption.

Reviews (2) · Last reviewed commit: "Derive FINAL_BYTE_MAX_DATA and cover the..."

@eeshsaxena eeshsaxena changed the title Reject 5-byte LEB128 encodings that overflow uint32 in decodeUInt32 fix: reject 5-byte LEB128 encodings that overflow uint32 in decodeUInt32 Sep 1, 2026
…ix path

- Compute FINAL_BYTE_MAX_DATA from FINAL_BYTE_SHIFT instead of hardcoding
  0x0F, so the two constants cannot drift if MAX_BYTES_FOR_UINT32 changes.
- Rename the 6-byte test to "throws for encoding longer than 5 bytes" so it
  no longer shares wording with the new "exceeds uint32 range" error.
- Add a vault.decrypt test that a payload whose key-length prefix is an
  over-range 5-byte LEB128 sequence rejects, exercising the guard through
  its only real caller.
@gjtorikian
gjtorikian merged commit fb303c9 into workos:main Sep 17, 2026
6 checks passed
@gjtorikian

Copy link
Copy Markdown
Contributor

thank you!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

2 participants