diff --git a/CHANGELOG.md b/CHANGELOG.md index d53cecd..3f80e77 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,7 @@ ### Fix -- set environment in create_release_pr (#46) +- fix encoder buffer overflow when a map value fills the initial 2 KB buffer exactly (#59) ### Refactor diff --git a/src/encode/encode.spec.ts b/src/encode/encode.spec.ts index 2381128..b3be18b 100644 --- a/src/encode/encode.spec.ts +++ b/src/encode/encode.spec.ts @@ -1,5 +1,6 @@ -import { it, describe, expect } from 'vitest'; +import { it, describe, expect, test, vi, beforeEach } from 'vitest'; import { encode, Replacer, encodeWithSelfDescribedTag } from './encode'; +import { decode } from '../decode/decode'; import { CborValue } from '../cbor-value'; function bytesToHexArray(arrayBuffer: Uint8Array): string[] { @@ -346,3 +347,94 @@ describe('encodeWithSelfDescribedTag', () => { expect(bytesToHexString(results[2])).toEqual('D9D9F7A2616505616606'); // { "e": 5, "f": 6 } with self-described tag }); }); + +// Regression: encoder throws "DataView setUint8 offset out of bounds" +// when a map value fills the 2KB buffer exactly, because the next key's +// encodeTextString → encodeHeader has no bounds check. +// +// Byte layout for encodeWithSelfDescribedTag({ a: Uint8Array(2039), b: 'x' }): +// Tag 55799: 3 bytes → s = 3 +// Map(2): 1 byte → s = 4 +// TextString "a": 2 bytes → s = 6 +// ByteString(2039): 3 + 2039 → s = 2048 (= initial buffer length) +// TextString "b": encodeHeader calls setUint8(2048, ...) → RangeError +// +// The encoder's internal buffer is module-level state that grows but never +// shrinks. Earlier tests (e.g. 16 MB string) expand it well past 2 KB, masking +// this bug. Each test below resets the module registry so it gets a fresh 2 KB +// buffer. +describe('encoder buffer overflow (upstream @dfinity/cbor bug)', () => { + let freshEncode: typeof encode; + let freshEncodeWithSelfDescribedTag: typeof encodeWithSelfDescribedTag; + let freshDecode: typeof decode; + + beforeEach(async () => { + vi.resetModules(); + const enc = await import('./encode'); + const dec = await import('../decode/decode'); + freshEncode = enc.encode; + freshEncodeWithSelfDescribedTag = enc.encodeWithSelfDescribedTag; + freshDecode = dec.decode; + }); + + test('encodeWithSelfDescribedTag: value exactly fills 2KB buffer', () => { + const payload = { a: new Uint8Array(2039), b: 'x' }; + + expect(() => freshEncodeWithSelfDescribedTag(payload)).not.toThrow(); + const decoded = freshDecode(freshEncodeWithSelfDescribedTag(payload)) as { + a: Uint8Array; + b: string; + }; + expect(decoded.b).toBe('x'); + expect(decoded.a.byteLength).toBe(2039); + }); + + test('encode (no self-described tag): value exactly fills 2KB buffer', () => { + // Without the 3-byte tag prefix, the boundary shifts: + // Map(2): 1 byte → s = 1, TextString "a": 2 bytes → s = 3, + // ByteString(2042): 3 + 2042 → s = 2048 + const payload = { a: new Uint8Array(2042), b: 'x' }; + + expect(() => freshEncode(payload)).not.toThrow(); + const decoded = freshDecode(freshEncode(payload)) as { + a: Uint8Array; + b: string; + }; + expect(decoded.b).toBe('x'); + expect(decoded.a.byteLength).toBe(2042); + }); + + test('envelope-shaped payload with large arg and delegation', () => { + const envelope = { + content: { + request_type: 'call', + canister_id: new Uint8Array(10), + method_name: 'manage_neuron', + arg: new Uint8Array(1961), + sender: new Uint8Array(29), + ingress_expiry: BigInt('0x17E83B35D5A00000'), + nonce: new Uint8Array(16), + }, + sender_sig: new Uint8Array(64), + sender_delegation: [ + { + delegation: { + pubkey: new Uint8Array(93), + expiration: BigInt('0x17E83B35D5A00000'), + }, + signature: new Uint8Array(1251), + }, + ], + sender_pubkey: new Uint8Array(62), + }; + + expect(() => freshEncodeWithSelfDescribedTag(envelope)).not.toThrow(); + const decoded = freshDecode( + freshEncodeWithSelfDescribedTag(envelope), + ) as Record; + expect(decoded).toHaveProperty('content'); + expect(decoded).toHaveProperty('sender_sig'); + expect(decoded).toHaveProperty('sender_delegation'); + expect(decoded).toHaveProperty('sender_pubkey'); + }); +}); diff --git a/src/encode/encode.ts b/src/encode/encode.ts index 4e9b38c..014740a 100644 --- a/src/encode/encode.ts +++ b/src/encode/encode.ts @@ -97,12 +97,16 @@ export function encodeWithSelfDescribedTag( return target.slice(0, bytesOffset); } -function encodeItem(item: CborValue, replacer?: Replacer): void { - if (bytesOffset > target.length - SAFE_BUFFER_END_OFFSET) { - target = resizeUint8Array(target, target.length * 2); - targetView = new DataView(target.buffer); +function growBuffer(minSize: number): void { + let newSize = target.length * 2; + while (newSize < minSize) { + newSize *= 2; } + target = resizeUint8Array(target, newSize); + targetView = new DataView(target.buffer); +} +function encodeItem(item: CborValue, replacer?: Replacer): void { if (item === false || item === true || item === null || item === undefined) { encodeSimple(item); return; @@ -161,6 +165,10 @@ function encodeMap(map: CborMap, replacer?: Replacer): void { } function encodeHeader(majorType: CborMajorType, value: CborNumber): void { + if (bytesOffset > target.length - SAFE_BUFFER_END_OFFSET) { + growBuffer(bytesOffset + SAFE_BUFFER_END_OFFSET); + } + if (value <= TOKEN_VALUE_MAX) { targetView.setUint8( bytesOffset++, @@ -240,8 +248,7 @@ function encodeBytes(majorType: CborMajorType, value: Uint8Array): void { encodeHeader(majorType, value.length); if (bytesOffset > target.length - value.length) { - target = resizeUint8Array(target, target.length + value.length); - targetView = new DataView(target.buffer); + growBuffer(bytesOffset + value.length); } target.set(value, bytesOffset); bytesOffset += value.length; diff --git a/tsconfig.test.json b/tsconfig.test.json index 7f50e7b..20bfb2c 100644 --- a/tsconfig.test.json +++ b/tsconfig.test.json @@ -1,6 +1,7 @@ { "extends": "./tsconfig.json", "compilerOptions": { + "module": "ESNext", "moduleResolution": "bundler" }, "include": ["./src/**/*", "./src/benchmarks/**/*", "./src/**/*.spec.ts"],