From a435257fe6532c687e3357caf5ea71ac96a033c1 Mon Sep 17 00:00:00 2001 From: Alice Date: Sun, 11 Oct 2026 08:27:15 +0700 Subject: [PATCH 1/2] fix(calldata): reject unsafe integers and unpaired surrogates in encoder MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit calldata.encode silently accepted: 1. Unsafe integer numbers (outside Number.isSafeInteger range) — BigInt(data) encoded the already-rounded Number 2. Strings with unpaired UTF-16 surrogates — TextEncoder replaced them with U+FFFD, causing decode(encode(value)) !== value and map key collisions Now rejects both with clear error messages, matching the GenVM Python reference encoder behavior. Closes #229 --- src/abi/calldata/encoder.ts | 36 +++++++++++++++++++++---- tests/calldata-encoder.test.ts | 49 ++++++++++++++++++++++++++++++++++ 2 files changed, 80 insertions(+), 5 deletions(-) create mode 100644 tests/calldata-encoder.test.ts diff --git a/src/abi/calldata/encoder.ts b/src/abi/calldata/encoder.ts index 63a792d..4990a3c 100644 --- a/src/abi/calldata/encoder.ts +++ b/src/abi/calldata/encoder.ts @@ -6,6 +6,21 @@ function reportError(msg: string, data: CalldataEncodable): never { throw new Error(`invalid calldata input '${data}'`); } +function hasUnpairedSurrogates(s: string): boolean { + for (let i = 0; i < s.length; i++) { + const code = s.charCodeAt(i); + if (code >= 0xd800 && code <= 0xdbff) { + if (i + 1 >= s.length) return true; + const next = s.charCodeAt(i + 1); + if (next < 0xdc00 || next > 0xdfff) return true; + i++; + } else if (code >= 0xdc00 && code <= 0xdfff) { + return true; + } + } + return false; +} + function writeNum(to: number[], data: bigint) { if (data === 0n) { to.push(0); @@ -48,11 +63,16 @@ function encodeMap(to: number[], arr: Iterable<[string, CalldataEncodable]>) { // unicode code points array, utf8 encoded array, item const newEntries: [number[], Uint8Array, CalldataEncodable][] = Array.from( arr, - ([k, v]): [number[], Uint8Array, CalldataEncodable] => [ - Array.from(k, x => x.codePointAt(0)!), - new TextEncoder().encode(k), - v, - ], + ([k, v]): [number[], Uint8Array, CalldataEncodable] => { + if (hasUnpairedSurrogates(k)) { + throw new Error(`unpaired UTF-16 surrogate in map key '${k}'`); + } + return [ + Array.from(k, x => x.codePointAt(0)!), + new TextEncoder().encode(k), + v, + ]; + }, ); newEntries.sort((v1, v2) => compareString(v1[0], v2[0])); for (let i = 1; i < newEntries.length; i++) { @@ -89,6 +109,9 @@ function encodeImpl(to: number[], data: CalldataEncodable) { if (!Number.isInteger(data)) { reportError("floats are not supported", data); } + if (!Number.isSafeInteger(data)) { + reportError("unsafe integer: use bigint for exact integers", data); + } encodeNum(to, BigInt(data)); return; } @@ -97,6 +120,9 @@ function encodeImpl(to: number[], data: CalldataEncodable) { return; } case "string": { + if (hasUnpairedSurrogates(data)) { + reportError("unpaired UTF-16 surrogate in string", data); + } const str = new TextEncoder().encode(data); encodeNumWithType(to, BigInt(str.length), consts.TYPE_STR); for (const c of str) { diff --git a/tests/calldata-encoder.test.ts b/tests/calldata-encoder.test.ts new file mode 100644 index 0000000..15b7466 --- /dev/null +++ b/tests/calldata-encoder.test.ts @@ -0,0 +1,49 @@ +import {describe, it, expect} from "vitest"; +import {encode} from "../src/abi/calldata/encoder"; + +describe("calldata encoder", () => { + describe("unsafe integers", () => { + it("rejects numbers outside safe integer range", () => { + expect(() => encode(Number.MAX_SAFE_INTEGER + 1)).toThrow(); + expect(() => encode(Number.MIN_SAFE_INTEGER - 1)).toThrow(); + }); + + it("accepts safe integers", () => { + expect(() => encode(Number.MAX_SAFE_INTEGER)).not.toThrow(); + expect(() => encode(Number.MIN_SAFE_INTEGER)).not.toThrow(); + expect(() => encode(0)).not.toThrow(); + expect(() => encode(-1)).not.toThrow(); + }); + + it("accepts bigint for large values", () => { + expect(() => encode(BigInt(Number.MAX_SAFE_INTEGER) + 1n)).not.toThrow(); + }); + }); + + describe("unpaired surrogates", () => { + it("rejects lone high surrogate in string", () => { + expect(() => encode("\uD800")).toThrow(); + }); + + it("rejects lone low surrogate in string", () => { + expect(() => encode("\uDC00")).toThrow(); + }); + + it("rejects high surrogate at end of string", () => { + expect(() => encode("hello\uD800")).toThrow(); + }); + + it("rejects unpaired surrogate in map key", () => { + expect(() => encode(new Map([["\uD800", null]]))).toThrow(); + }); + + it("accepts valid surrogate pairs (emoji)", () => { + expect(() => encode("hello 🌍")).not.toThrow(); + }); + + it("accepts normal unicode strings", () => { + expect(() => encode("hello world")).not.toThrow(); + expect(() => encode("")).not.toThrow(); + }); + }); +}); From 1f746422de64433000e99d9fd04d487abccdd288 Mon Sep 17 00:00:00 2001 From: Alice Date: Sun, 11 Oct 2026 08:33:09 +0700 Subject: [PATCH 2/2] fix(calldata): reject non-canonical wire data in decoder calldata.decode was more permissive than the GenVM reference decoder: 1. readULeb128 accepted overlong/non-canonical encodings 2. readULeb128 read past end of input as undefined 3. Map decoding did not enforce canonical key order or reject duplicates 4. Strings and map keys used non-fatal TextDecoder for invalid UTF-8 Now rejects all of these at the point they are encountered. Closes #227 --- src/abi/calldata/decoder.ts | 26 ++++++++++++++-- tests/calldata-decoder.test.ts | 56 ++++++++++++++++++++++++++++++++++ 2 files changed, 80 insertions(+), 2 deletions(-) create mode 100644 tests/calldata-decoder.test.ts diff --git a/src/abi/calldata/decoder.ts b/src/abi/calldata/decoder.ts index 6b7680b..d08ff72 100644 --- a/src/abi/calldata/decoder.ts +++ b/src/abi/calldata/decoder.ts @@ -6,13 +6,24 @@ function readULeb128(data: Uint8Array, index: {i: number}): bigint { let res: bigint = 0n; let accum = 0n; let shouldContinue = true; + let bytesRead = 0; while (shouldContinue) { + if (index.i >= data.length) { + throw new Error("unexpected end of input in ULEB128"); + } const byte = data[index.i]; index.i++; + bytesRead++; const rest = byte & 0x7f; res += BigInt(rest) * (1n << accum); accum += 7n; shouldContinue = byte >= 128; + if (shouldContinue && rest === 0n && bytesRead > 1) { + throw new Error("non-canonical ULEB128: overlong encoding"); + } + } + if (bytesRead > 1 && res === 0n) { + throw new Error("non-canonical ULEB128: overlong encoding"); } return res; } @@ -45,9 +56,12 @@ function decodeImpl(data: Uint8Array, index: {i: number}): CalldataEncodable { case consts.TYPE_NINT: return -1n - rest; case consts.TYPE_STR: { + if (index.i + Number(rest) > data.length) { + throw new Error("string extends past end of input"); + } const ret = data.slice(index.i, index.i + Number(rest)); index.i += Number(rest); - return new TextDecoder("utf-8").decode(ret); + return new TextDecoder("utf-8", {fatal: true}).decode(ret); } case consts.TYPE_ARR: { const ret = [] as CalldataEncodable[]; @@ -61,12 +75,20 @@ function decodeImpl(data: Uint8Array, index: {i: number}): CalldataEncodable { case consts.TYPE_MAP: { const ret = new Map(); let elems = rest; + let prevKey: string | null = null; while (elems > 0) { elems--; const strLen = Number(readULeb128(data, index)); + if (index.i + strLen > data.length) { + throw new Error("map key extends past end of input"); + } const key = data.slice(index.i, index.i + strLen); index.i += strLen; - const keyStr = new TextDecoder("utf-8").decode(key); + const keyStr = new TextDecoder("utf-8", {fatal: true}).decode(key); + if (prevKey !== null && keyStr <= prevKey) { + throw new Error("map keys must be in strictly increasing order"); + } + prevKey = keyStr; ret.set(keyStr, decodeImpl(data, index)); } return ret; diff --git a/tests/calldata-decoder.test.ts b/tests/calldata-decoder.test.ts new file mode 100644 index 0000000..a8abe4d --- /dev/null +++ b/tests/calldata-decoder.test.ts @@ -0,0 +1,56 @@ +import {describe, it, expect} from "vitest"; +import {decode} from "../src/abi/calldata/decoder"; + +describe("calldata decoder", () => { + describe("ULEB128 validation", () => { + it("rejects overlong ULEB128 encoding", () => { + expect(() => decode(new Uint8Array([0x80, 0x00]))).toThrow(); + }); + + it("rejects truncated ULEB128", () => { + expect(() => decode(new Uint8Array([0x80]))).toThrow(); + }); + + it("rejects empty input", () => { + expect(() => decode(new Uint8Array([]))).toThrow(); + }); + }); + + describe("map key ordering", () => { + it("rejects unordered map keys", () => { + expect(() => + decode(new Uint8Array([0x16, 0x01, 0x62, 0x00, 0x01, 0x61, 0x00])), + ).toThrow(); + }); + + it("rejects duplicate map keys", () => { + expect(() => + decode(new Uint8Array([0x16, 0x01, 0x61, 0x00, 0x01, 0x61, 0x00])), + ).toThrow(); + }); + }); + + describe("UTF-8 validation", () => { + it("rejects invalid UTF-8 in strings", () => { + expect(() => decode(new Uint8Array([0x0c, 0xff]))).toThrow(); + }); + + it("rejects invalid UTF-8 in map keys", () => { + expect(() => + decode(new Uint8Array([0x16, 0x01, 0xff, 0x00, 0x01, 0x61, 0x00])), + ).toThrow(); + }); + }); + + describe("bounds checking", () => { + it("rejects string extending past end of input", () => { + expect(() => decode(new Uint8Array([0x0c, 0xff, 0xff, 0xff]))).toThrow(); + }); + + it("rejects map key extending past end of input", () => { + expect(() => + decode(new Uint8Array([0x16, 0x01, 0xff, 0xff, 0xff, 0x00])), + ).toThrow(); + }); + }); +});