From 0a0347755232ba80f820dc689af9083107ed1cc7 Mon Sep 17 00:00:00 2001 From: solidsnakedev Date: Fri, 16 Oct 2026 13:16:40 -0600 Subject: [PATCH 1/6] 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/6] 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/6] 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. From c144e04f7a851ddd8b82fdfdf9fc8b2fa5f05d69 Mon Sep 17 00:00:00 2001 From: solidsnakedev Date: Fri, 16 Oct 2026 16:33:42 -0600 Subject: [PATCH 4/6] fix(cbor): read 4-byte lengths as unsigned --- packages/evolution/src/CBOR.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/evolution/src/CBOR.ts b/packages/evolution/src/CBOR.ts index 29f2bd1c..33e002a4 100644 --- a/packages/evolution/src/CBOR.ts +++ b/packages/evolution/src/CBOR.ts @@ -2018,7 +2018,7 @@ const decodeLengthAt = (data: Uint8Array, offset: number): { length: number; byt } else if (additionalInfo === 26) { if (data.length < offset + 5) throw new CBORError({ message: "Insufficient data for 4-byte length" }) return { - length: (data[offset + 1] << 24) | (data[offset + 2] << 16) | (data[offset + 3] << 8) | data[offset + 4], + length: ((data[offset + 1] << 24) | (data[offset + 2] << 16) | (data[offset + 3] << 8) | data[offset + 4]) >>> 0, bytesRead: 5 } } From b9edb0ae25ec64334637271696c24d7882b875fa Mon Sep 17 00:00:00 2001 From: solidsnakedev Date: Fri, 16 Oct 2026 16:33:42 -0600 Subject: [PATCH 5/6] test(cbor): cover 4-byte lengths of 2^31 and more --- packages/evolution/test/CBOR.DecodeLength.test.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/packages/evolution/test/CBOR.DecodeLength.test.ts b/packages/evolution/test/CBOR.DecodeLength.test.ts index 1e75f276..1ea8c8fb 100644 --- a/packages/evolution/test/CBOR.DecodeLength.test.ts +++ b/packages/evolution/test/CBOR.DecodeLength.test.ts @@ -27,7 +27,13 @@ describe("CBOR decoding of declared lengths", () => { { 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" } + { name: "a tag with no content", hex: "d818", message: "Insufficient data for CBOR item" }, + { name: "an array declaring 2^31 items with none present", hex: "9a80000000", message: "Insufficient data for array" }, + { name: "a map declaring 2^31 entries with none present", hex: "ba80000000", message: "Insufficient data for map" }, + { name: "a byte string declaring 2^32 - 1 bytes", hex: "5affffffff", message: "Insufficient data for byte string" }, + { name: "an array holding a byte string that declares 2^31 bytes", hex: "825a8000000000", message: "Insufficient data for byte string" }, + { name: "a byte string chunk declaring 2^32 - 1 bytes", hex: "5f5affffffffff", message: "Insufficient data for byte string chunk" }, + { name: "an indefinite array holding a text string that declares 2^32 - 5 bytes", hex: "9f7afffffffbff", message: "Insufficient data for text string" } ] for (const [label, decode] of decoders) { From ec356ac3365e7c3fff5d35a91598accfb127778f Mon Sep 17 00:00:00 2001 From: solidsnakedev Date: Fri, 16 Oct 2026 16:33:42 -0600 Subject: [PATCH 6/6] release: changeset for unsigned 4-byte lengths --- .changeset/cbor-declared-length.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/cbor-declared-length.md b/.changeset/cbor-declared-length.md index f95c2a63..6ecde248 100644 --- a/.changeset/cbor-declared-length.md +++ b/.changeset/cbor-declared-length.md @@ -2,4 +2,4 @@ "@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. +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. The decoder also read a 4-byte length of 2^31 or more as a negative number, so the seven bytes `9f7afffffffbff` decoded the same item over and over until memory ran out. It now reads lengths unsigned. All of these now throw `CBORError` at once, as other truncated input already did. Well-formed CBOR decodes as before.