From 0a0347755232ba80f820dc689af9083107ed1cc7 Mon Sep 17 00:00:00 2001 From: solidsnakedev Date: Fri, 16 Oct 2026 13:16:40 -0600 Subject: [PATCH 1/3] fix(cbor): stop decoding at the end of the input and bound declared lengths decodeItemAt throws CBORError at an offset past the end of the input, and decodeArrayAt and decodeMapAt reject a definite length larger than the bytes left before they loop or allocate. --- packages/evolution/src/CBOR.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/packages/evolution/src/CBOR.ts b/packages/evolution/src/CBOR.ts index 3bb1e7c7..29f2bd1c 100644 --- a/packages/evolution/src/CBOR.ts +++ b/packages/evolution/src/CBOR.ts @@ -1904,6 +1904,7 @@ const bytesReadToByteSize = (bytesRead: number): ByteSize => bytesRead <= 1 ? 0 : bytesRead === 2 ? 1 : bytesRead === 3 ? 2 : 4 const decodeItemAt = (data: Uint8Array, offset: number, options: CodecOptions, track: DecodeTrack): DecodeAtResult => { + if (offset >= data.length) throw new CBORError({ message: "Insufficient data for CBOR item" }) const firstByte = data[offset] const majorType = (firstByte >> 5) & 0x07 let result: DecodeAtResult @@ -2143,6 +2144,8 @@ const decodeArrayAt = (data: Uint8Array, offset: number, options: CodecOptions, } else { const { bytesRead, length } = decodeLengthAt(data, offset) let cur = offset + bytesRead + // Every item takes at least one byte + if (length > data.length - cur) throw new CBORError({ message: "Insufficient data for array" }) const arr: Array = new Array(length) if (track === "none") { for (let i = 0; i < length; i++) { @@ -2197,6 +2200,8 @@ const decodeMapAt = (data: Uint8Array, offset: number, options: CodecOptions, tr } else { const { bytesRead, length } = decodeLengthAt(data, offset) let cur = offset + bytesRead + // Every entry takes at least two bytes, one for the key and one for the value + if (length > (data.length - cur) / 2) throw new CBORError({ message: "Insufficient data for map" }) const map = isObj ? ({} as Record) : new Map() if (track === "none") { for (let i = 0; i < length; i++) { From adb8ab25dfcb3e24d5c253fc82662c4685ca03b0 Mon Sep 17 00:00:00 2001 From: solidsnakedev Date: Fri, 16 Oct 2026 13:16:40 -0600 Subject: [PATCH 2/3] test(cbor): cover declared lengths the input cannot hold --- .../evolution/test/CBOR.DecodeLength.test.ts | 52 +++++++++++++++++++ 1 file changed, 52 insertions(+) create mode 100644 packages/evolution/test/CBOR.DecodeLength.test.ts diff --git a/packages/evolution/test/CBOR.DecodeLength.test.ts b/packages/evolution/test/CBOR.DecodeLength.test.ts new file mode 100644 index 00000000..1e75f276 --- /dev/null +++ b/packages/evolution/test/CBOR.DecodeLength.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, it } from "@effect/vitest" + +import * as CBOR from "../src/CBOR.js" +import * as Transaction from "../src/Transaction.js" + +// A definite array or map header declares how many items follow. The decoder +// must not read past the end of the input or trust a length the input cannot hold. + +const decoders = [ + ["CBOR.fromCBORHex", (hex: string) => CBOR.fromCBORHex(hex)], + ["CBOR.fromCBORHexWithFormat", (hex: string) => CBOR.fromCBORHexWithFormat(hex)], + ["Transaction.fromCBORHex", (hex: string) => Transaction.fromCBORHex(hex)] +] as const + +const decodeError = (decode: (hex: string) => unknown, hex: string): unknown => { + try { + decode(hex) + } catch (error) { + return error + } + return undefined +} + +describe("CBOR decoding of declared lengths", () => { + const cases = [ + { name: "an array declaring 2^31 - 1 items with none present", hex: "9a7fffffff", message: "Insufficient data for array" }, + { name: "a map declaring 2^31 - 1 entries with none present", hex: "ba7fffffff", message: "Insufficient data for map" }, + { name: "an array missing its last item", hex: "8201", message: "Insufficient data for array" }, + { name: "a map missing its value", hex: "a101", message: "Insufficient data for map" }, + { name: "a tag with no content", hex: "d818", message: "Insufficient data for CBOR item" } + ] + + for (const [label, decode] of decoders) { + it.each(cases)(`${label} throws CBORError for $name`, ({ hex, message }) => { + const error = decodeError(decode, hex) + expect(error).toBeInstanceOf(CBOR.CBORError) + expect((error as CBOR.CBORError).message).toContain(message) + }) + } + + it("decodes arrays and maps whose items fill the input exactly", () => { + expect(CBOR.fromCBORHex("83010203")).toEqual([1n, 2n, 3n]) + expect(CBOR.fromCBORHex("a201020304")).toEqual( + new Map([ + [1n, 2n], + [3n, 4n] + ]) + ) + expect(CBOR.fromCBORHex("8180")).toEqual([[]]) + expect(CBOR.fromCBORHex("9f0102ff")).toEqual([1n, 2n]) + }) +}) From d855716fec4d37ab0cf1a3376a0c982c91120583 Mon Sep 17 00:00:00 2001 From: solidsnakedev Date: Fri, 16 Oct 2026 13:16:40 -0600 Subject: [PATCH 3/3] release: changeset for cbor declared lengths --- .changeset/cbor-declared-length.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/cbor-declared-length.md diff --git a/.changeset/cbor-declared-length.md b/.changeset/cbor-declared-length.md new file mode 100644 index 00000000..f95c2a63 --- /dev/null +++ b/.changeset/cbor-declared-length.md @@ -0,0 +1,5 @@ +--- +"@evolution-sdk/evolution": patch +--- + +The CBOR decoder no longer reads past the end of its input or trusts a declared array or map length that the input cannot hold. The five bytes `9a7fffffff` declare an array of 2^31 - 1 items, and `CBOR.fromCBORHex` and `Transaction.fromCBORHex` allocated for them until the process ran out of memory. The map form `ba7fffffff` ran for tens of seconds. Both now throw `CBORError` at once, as other truncated input already did. Well-formed CBOR decodes as before.