From 6a7fd488e2754132e4950a1bc6e0ddc458e117f4 Mon Sep 17 00:00:00 2001 From: eeshsaxena <139802361+eeshsaxena@users.noreply.github.com> Date: Tue, 1 Sep 2026 23:19:45 +0530 Subject: [PATCH 1/2] Reject 5-byte LEB128 encodings that overflow uint32 in decodeUInt32 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. --- src/common/utils/leb128.spec.ts | 22 ++++++++++++++++++++++ src/common/utils/leb128.ts | 15 +++++++++++++++ 2 files changed, 37 insertions(+) diff --git a/src/common/utils/leb128.spec.ts b/src/common/utils/leb128.spec.ts index 869e59e9c..ed936f917 100644 --- a/src/common/utils/leb128.spec.ts +++ b/src/common/utils/leb128.spec.ts @@ -242,6 +242,28 @@ describe('leb128', () => { 'LEB128 sequence exceeds maximum length for uint32', ); }); + + test('throws for a 5-byte encoding whose final byte overflows uint32', () => { + // 5 bytes, but the final byte carries data bits above bit 31 + // (0x10 -> 2^32). Without a range check the 32-bit shift silently drops + // the overflow and returns 0 instead of rejecting the value. + const data = new Uint8Array([0x80, 0x80, 0x80, 0x80, 0x10]); + expect(() => decodeUInt32(data)).toThrow( + 'LEB128 sequence exceeds uint32 range', + ); + }); + + test('throws when the final byte uses all 7 data bits', () => { + const data = new Uint8Array([0xff, 0xff, 0xff, 0xff, 0x7f]); + expect(() => decodeUInt32(data)).toThrow( + 'LEB128 sequence exceeds uint32 range', + ); + }); + + test('accepts MAX_UINT32 whose final byte is exactly 0x0f', () => { + const data = new Uint8Array([0xff, 0xff, 0xff, 0xff, 0x0f]); + expect(decodeUInt32(data).value).toBe(4294967295); + }); }); }); diff --git a/src/common/utils/leb128.ts b/src/common/utils/leb128.ts index 8f6978fa8..463556844 100644 --- a/src/common/utils/leb128.ts +++ b/src/common/utils/leb128.ts @@ -9,6 +9,11 @@ const CONTINUATION_BIT = 0x80; const DATA_BITS_MASK = 0x7f; const DATA_BITS_PER_BYTE = 7; const MAX_BYTES_FOR_UINT32 = 5; +// The 5th byte is read at this shift; a uint32 only has 32 - 28 = 4 data bits +// left for it, so its 7-bit payload must not exceed 0x0F. A larger payload +// encodes a value above MAX_UINT32. +const FINAL_BYTE_SHIFT = (MAX_BYTES_FOR_UINT32 - 1) * DATA_BITS_PER_BYTE; +const FINAL_BYTE_MAX_DATA = 0x0f; /** * Encodes an unsigned 32-bit integer into LEB128 format. @@ -65,6 +70,16 @@ export function decodeUInt32( throw new Error('LEB128 sequence exceeds maximum length for uint32'); } + // On the final (5th) byte only 4 of its 7 data bits fit in a uint32. Reject a + // larger payload instead of letting the 32-bit `<<` below silently drop the + // overflowing bits and return a wrong value for an out-of-range encoding. + if ( + shift === FINAL_BYTE_SHIFT && + (byte & DATA_BITS_MASK) > FINAL_BYTE_MAX_DATA + ) { + throw new Error('LEB128 sequence exceeds uint32 range'); + } + result |= (byte & DATA_BITS_MASK) << shift; if (!hasContinuationBit(byte)) { From fb9696c8c0edb78dcae6207ba5706da3868b8ffb Mon Sep 17 00:00:00 2001 From: "Garen J. Torikian" Date: Thu, 17 Sep 2026 12:46:56 -0400 Subject: [PATCH 2/2] Derive FINAL_BYTE_MAX_DATA and cover the vault.decrypt malformed-prefix 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. --- src/common/utils/leb128.spec.ts | 2 +- src/common/utils/leb128.ts | 2 +- src/vault/vault.spec.ts | 18 ++++++++++++++++++ 3 files changed, 20 insertions(+), 2 deletions(-) diff --git a/src/common/utils/leb128.spec.ts b/src/common/utils/leb128.spec.ts index ed936f917..c4d02142a 100644 --- a/src/common/utils/leb128.spec.ts +++ b/src/common/utils/leb128.spec.ts @@ -235,7 +235,7 @@ describe('leb128', () => { expect(() => decodeUInt32(data)).toThrow('Truncated LEB128 encoding'); }); - test('throws for encoding that exceeds uint32 range', () => { + test('throws for encoding longer than 5 bytes', () => { // 6 bytes with continuation bits (should never happen for uint32) const data = new Uint8Array([0x80, 0x80, 0x80, 0x80, 0x80, 0x01]); expect(() => decodeUInt32(data)).toThrow( diff --git a/src/common/utils/leb128.ts b/src/common/utils/leb128.ts index 463556844..928b9e770 100644 --- a/src/common/utils/leb128.ts +++ b/src/common/utils/leb128.ts @@ -13,7 +13,7 @@ const MAX_BYTES_FOR_UINT32 = 5; // left for it, so its 7-bit payload must not exceed 0x0F. A larger payload // encodes a value above MAX_UINT32. const FINAL_BYTE_SHIFT = (MAX_BYTES_FOR_UINT32 - 1) * DATA_BITS_PER_BYTE; -const FINAL_BYTE_MAX_DATA = 0x0f; +const FINAL_BYTE_MAX_DATA = (1 << (32 - FINAL_BYTE_SHIFT)) - 1; /** * Encodes an unsigned 32-bit integer into LEB128 format. diff --git a/src/vault/vault.spec.ts b/src/vault/vault.spec.ts index 8903e7200..c2a52b829 100644 --- a/src/vault/vault.spec.ts +++ b/src/vault/vault.spec.ts @@ -274,6 +274,24 @@ describe('Vault', () => { expect(fetchMethod()).toBe('POST'); expect(decrypted).toBe(originalText); }); + + it('rejects a payload whose key-length prefix overflows uint32', async () => { + // 12-byte IV + 16-byte tag as filler, then a 5-byte LEB128 length whose + // final byte (0x10) encodes 2^32, which is out of range for a uint32. + const payload = new Uint8Array([ + ...new Array(28).fill(0), + 0x80, + 0x80, + 0x80, + 0x80, + 0x10, + ]); + const encoded = Buffer.from(payload).toString('base64'); + + await expect(workos.vault.decrypt(encoded)).rejects.toThrow( + 'LEB128 sequence exceeds uint32 range', + ); + }); }); // @oagen-ignore-end });