From b51aa0558b5133e6079a66a3d7de5d7d53842709 Mon Sep 17 00:00:00 2001 From: Ray Walker Date: Mon, 31 Aug 2026 02:58:58 +1000 Subject: [PATCH] fix(serialization): fire map/object collection cap before key materialisation (LAB-413) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit encodeMapEntries materialised every utf8Strict key encoding and ran the full byte-order sort before encodeMapHeader's collection-size cap fired, and the key-encoding phase never passes through pushChunk, so the byte budget gave no backstop either (CWE-770/CWE-400, availability-only, args-profile reachable via request-derived map/object arguments). Map keys are unique by construction — unlike Sets (PR #72), no dedupe can shrink the count — so a single up-front checkCollectionSize on entries.length has identical accept/reject semantics and unchanged canonical bytes for every accepted input. The Map branch additionally pre-checks the O(1) .size so an over-cap Map is rejected before its entry tuples are built at all. Regression tests pin the ordering: an iterator spy proves an over-cap Map is never iterated, and a lone-surrogate first key proves the cap wins against utf8Strict on plain objects. --- .../src/serialization/interop.test.ts | 44 +++++++++++++++++++ .../cachekit/src/serialization/interop.ts | 10 +++++ 2 files changed, 54 insertions(+) diff --git a/packages/cachekit/src/serialization/interop.test.ts b/packages/cachekit/src/serialization/interop.test.ts index fccaf69..416b8e6 100644 --- a/packages/cachekit/src/serialization/interop.test.ts +++ b/packages/cachekit/src/serialization/interop.test.ts @@ -199,6 +199,50 @@ describe('interop Set encoding budgets (encodeCanonical, shared by both profiles }); }); +describe('interop map/object collection cap timing (encodeMapEntries)', () => { + it('rejects an over-cap Map before iterating a single entry', () => { + // Map.size is O(1), so the cap must fire before the entry loop runs — + // otherwise 10,001 tuples materialise pre-cap. The own-property iterator + // spy shadows Map.prototype[Symbol.iterator] and counts pulls. + const m = new Map(Array.from({ length: 10_001 }, (_, i) => [`k${i}`, 0])); + let iterated = 0; + const inner = Map.prototype[Symbol.iterator].bind(m); + Object.defineProperty(m, Symbol.iterator, { + value: function* (): Generator<[string, number]> { + for (const e of inner()) { + iterated++; + yield e as [string, number]; + } + }, + }); + expect(() => encodeInteropValue(m)).toThrow(ValueTooLargeError); + expect(iterated).toBe(0); + }); + + it('rejects an over-cap plain object before any key is UTF-8 encoded or sorted', () => { + // The first-iterated key is a lone surrogate: if any key reached + // utf8Strict, the encoder would throw SerializationError (well-formedness) + // instead of ValueTooLargeError. The cap winning pins the ordering — the + // count check fires before key materialisation. + const obj: Record = { '\ud800': 0 }; + for (let i = 0; i < 10_001; i++) obj[`k${i}`] = 0; + expect(() => encodeInteropValue(obj)).toThrow(ValueTooLargeError); + // Same object one key under the cap: key encoding now runs and the lone + // surrogate is what rejects it (proves the spy key is actually live). + const under: Record = { '\ud800': 0 }; + for (let i = 0; i < 9_998; i++) under[`k${i}`] = 0; + expect(() => encodeInteropValue(under)).toThrow(/well-formed Unicode|lone surrogates/); + }); + + it('accepts a Map at exactly the cap with unchanged canonical bytes', () => { + const atCap = new Map(Array.from({ length: 10_000 }, (_, i) => [`k${i}`, i])); + const bytes = encodeInteropValue(atCap); + // Object form of the same entries encodes byte-identically (shared + // encodeMapEntries path, key-sorted canonical form). + expect(hex(encodeInteropValue(Object.fromEntries(atCap)))).toBe(hex(bytes)); + }); +}); + describe('interop value encoding (value profile)', () => { it('maps undefined to nil (no cross-SDK arity contract for values)', () => { expect(hex(encodeInteropValue(undefined))).toBe('c0'); diff --git a/packages/cachekit/src/serialization/interop.ts b/packages/cachekit/src/serialization/interop.ts index 721977f..7147309 100644 --- a/packages/cachekit/src/serialization/interop.ts +++ b/packages/cachekit/src/serialization/interop.ts @@ -339,6 +339,13 @@ function encodeMapEntries( depth: number, sink: ChunkSink ): void { + // Cap BEFORE materialising key encodings: map keys are unique by + // construction, so the entry count is final up front — unlike Sets, no + // dedupe can shrink it. Checking here (rather than in encodeMapHeader + // after the map/sort below) keeps an over-cap map from forcing N + // Uint8Array allocations plus an O(N log N) sort that never pass through + // pushChunk's byte budget. + checkCollectionSize(entries.length, 'map'); // Sort keys by UTF-8 byte order (== Unicode code point order). The default // Array.prototype.sort comparator orders UTF-16 code units and gets // supplementary-plane characters backwards (map_key_sort_supplementary). @@ -454,6 +461,9 @@ function encodeCanonical( encodeCanonical(item, profile, depth + 1, sink); } } else if (v instanceof Map) { + // Map.size is O(1) — reject over-cap maps before iterating at all, so + // the tuple materialisation below is also bounded. + checkCollectionSize(v.size, 'map'); const entries: [string, unknown][] = []; for (const [k, val] of v) { if (typeof k !== 'string') {