diff --git a/src/common/utils/leb128.spec.ts b/src/common/utils/leb128.spec.ts index 869e59e9c..c4d02142a 100644 --- a/src/common/utils/leb128.spec.ts +++ b/src/common/utils/leb128.spec.ts @@ -235,13 +235,35 @@ 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( '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..928b9e770 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 = (1 << (32 - FINAL_BYTE_SHIFT)) - 1; /** * 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)) { 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 });