Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 23 additions & 1 deletion src/common/utils/leb128.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});
});

Expand Down
15 changes: 15 additions & 0 deletions src/common/utils/leb128.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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)) {
Expand Down
18 changes: 18 additions & 0 deletions src/vault/vault.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
});
Loading