Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions packages/cachekit/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -180,6 +180,16 @@ const cache = createCache.minimal({
});
```

`maxDecodedSize` is also a **security bound**: backend bytes are untrusted, and
decoding materialises them into objects that cost several times their wire size
in heap (a legal payload can inflate ~40×). Nested forged collection headers
can no longer amplify unbounded — reads are structurally depth-bounded before
the decoder allocates (LAB-2487) — but `maxDecodedSize` still sets the ceiling
on a single untrusted decode's transient memory. Size it against your runtime's
memory limit (and, on a shared/concurrent runtime, against peak concurrent
reads), not just your largest value: on a 128 MiB Workers isolate a 10 MiB cap
already permits a multi-hundred-MiB transient.

The SDK also reports every size rejection through its
[pluggable logger](#observability) as a rate-limited, greppable
`[cachekit] set rejected, value NOT cached (keyHash=...)` line — watch for it
Expand Down
14 changes: 14 additions & 0 deletions packages/cachekit/src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,20 @@ export const DEFAULT_MAX_DEPTH = 100;
/** Maximum collection size for Maps, Sets, Arrays, Objects */
export const DEFAULT_MAX_COLLECTION_SIZE = 10000;

/**
* Maximum decoded size for an invalidation pub/sub event (4KB).
*
* Events are a fixed flat map of 5 scalar fields — a generous one encodes to a
* few hundred bytes. The pub/sub bytes are untrusted (same backend-write
* attacker as cache reads), so this least-privilege cap keeps a forged event
* from riding the 10MB value ceiling; combined with a shallow depth bound it
* shrinks the decode blast radius on this path by ~1000x.
*/
export const DEFAULT_MAX_INVALIDATION_EVENT_SIZE = 4096;

/** Maximum nesting depth for an invalidation event (a flat map — no nesting). */
export const MAX_INVALIDATION_EVENT_DEPTH = 3;

/** Maximum size for key generation (64KB) */
export const KEY_GEN_MAX_SIZE = 64 * 1024;

Expand Down
20 changes: 20 additions & 0 deletions packages/cachekit/src/invalidation/event.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,4 +49,24 @@ describe('InvalidationEvent serialization', () => {
// map16 claiming 65535 entries.
expect(() => deserializeEvent(Uint8Array.of(0xde, 0xff, 0xff))).toThrow();
});

it('rejects an oversized event at the PUBLISHER, not just the subscriber (LAB-2487)', () => {
// If only deserializeEvent enforced the cap, an oversized event would be
// silently rejected by every subscriber — invalidation lost, stale L1
// served — with no signal to the publisher. serializeEvent must throw so
// the caller can act.
const oversized = createInvalidationEvent('namespace', 'instance-1', {
namespace: 'n'.repeat(5000),
});
expect(() => serializeEvent(oversized)).toThrow(/exceeds max/);

// Publish/subscribe symmetry: anything serializeEvent accepts,
// deserializeEvent must accept back (no event a publisher can emit is
// droppable on read for size).
const atSanityEdge = createInvalidationEvent('params', 'instance-1', {
namespace: 'n'.repeat(1000),
paramsHash: 'f'.repeat(64),
});
expect(deserializeEvent(serializeEvent(atSanityEdge)).namespace).toBe('n'.repeat(1000));
});
});
34 changes: 27 additions & 7 deletions packages/cachekit/src/invalidation/event.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
import { encode, decode } from '@msgpack/msgpack';
import type { InvalidationLevel, InvalidationEvent } from '../l1/types.js';
import { boundedDecodeOptions } from '../serialization/serializer.js';
import { DEFAULT_MAX_COLLECTION_SIZE, DEFAULT_MAX_DECODED_SIZE } from '../constants.js';
import { assertDecodeDepth, boundedDecodeOptions } from '../serialization/serializer.js';
import {
DEFAULT_MAX_COLLECTION_SIZE,
DEFAULT_MAX_INVALIDATION_EVENT_SIZE,
MAX_INVALIDATION_EVENT_DEPTH,
} from '../constants.js';
import { SerializationError } from '../errors.js';

/**
Expand All @@ -25,6 +29,13 @@ interface CompactEvent {

/**
* Serialize an InvalidationEvent to bytes for transmission.
*
* Enforces the same size cap as {@link deserializeEvent}: an event over the
* cap would be rejected by every subscriber — the invalidation silently lost
* and stale L1 entries kept — so it fails HERE, at the publisher, where the
* caller (whose namespace makes the event oversized) can act on the error.
*
* @throws {SerializationError} if the encoded event exceeds the event size cap
*/
export function serializeEvent(event: InvalidationEvent): Uint8Array {
const compact: CompactEvent = {
Expand All @@ -40,27 +51,36 @@ export function serializeEvent(event: InvalidationEvent): Uint8Array {
compact.ph = event.paramsHash;
}

return encode(compact);
const bytes = encode(compact);
if (bytes.length > DEFAULT_MAX_INVALIDATION_EVENT_SIZE) {
throw new SerializationError(
`Invalidation event size ${bytes.length} exceeds max ${DEFAULT_MAX_INVALIDATION_EVENT_SIZE}`
);
}
return bytes;
}

/**
* Deserialize bytes to an InvalidationEvent.
*
* Pub/sub bytes are untrusted (same backend-write attacker as cache reads),
* so decoding is bounded — full rationale: boundedDecodeOptions in
* serializer.ts.
* serializer.ts. An event is a fixed flat map of scalars, so this path is
* held to a much tighter size + depth cap than a general cache value
* (least privilege: a forged event cannot ride the 10MB value ceiling).
*
* @throws {SerializationError} if input exceeds the decode size cap
*/
export function deserializeEvent(data: Uint8Array): InvalidationEvent {
if (data.length > DEFAULT_MAX_DECODED_SIZE) {
if (data.length > DEFAULT_MAX_INVALIDATION_EVENT_SIZE) {
throw new SerializationError(
`Invalidation event size ${data.length} exceeds max ${DEFAULT_MAX_DECODED_SIZE}`
`Invalidation event size ${data.length} exceeds max ${DEFAULT_MAX_INVALIDATION_EVENT_SIZE}`
);
}
Comment thread
27Bslash6 marked this conversation as resolved.
assertDecodeDepth(data, MAX_INVALIDATION_EVENT_DEPTH);
const compact = decode(
data,
boundedDecodeOptions(DEFAULT_MAX_COLLECTION_SIZE, DEFAULT_MAX_DECODED_SIZE)
boundedDecodeOptions(DEFAULT_MAX_COLLECTION_SIZE, DEFAULT_MAX_INVALIDATION_EVENT_SIZE)
) as CompactEvent;

return {
Expand Down
21 changes: 19 additions & 2 deletions packages/cachekit/src/invalidation/redis-channel.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -181,7 +181,7 @@ describe('RedisInvalidationChannel', () => {
const event = {
level: 'namespace' as const,
namespace: 'users',
timestamp: Date.now(),
timestamp: 12345,
sourceInstance: 'inst-1',
};
channel.publish(event);
Expand All @@ -200,7 +200,7 @@ describe('RedisInvalidationChannel', () => {
channel.publish({
level: 'namespace',
namespace: 'users',
timestamp: Date.now(),
timestamp: 12345,
sourceInstance: 'inst-1',
});

Expand Down Expand Up @@ -379,4 +379,21 @@ describe('RedisInvalidationChannel', () => {
await expect(channel.stop()).resolves.toBeUndefined();
});
});

describe('LAB-2487: oversized event on publish', () => {
it('publish() never throws and never transmits an event serializeEvent rejects', () => {
const mockRedis = createMockRedis();
const channel = new RedisInvalidationChannel(mockRedis);
const oversized = {
level: 'namespace' as const,
namespace: 'n'.repeat(5000),
timestamp: 12345,
sourceInstance: 'instance-1',
};
// publish() is fire-and-forget by contract: the serializeEvent size
// rejection must be logged, not propagated into the caller's write path.
expect(() => channel.publish(oversized)).not.toThrow();
expect(mockRedis.publish).not.toHaveBeenCalled();
});
});
});
15 changes: 14 additions & 1 deletion packages/cachekit/src/invalidation/redis-channel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,20 @@ export class RedisInvalidationChannel {
* This is intentional: invalidation is best-effort optimization.
*/
publish(event: InvalidationEvent): void {
const data = serializeEvent(event);
let data: Uint8Array;
try {
data = serializeEvent(event);
} catch (err) {
// serializeEvent throws on an oversized event (LAB-2487) — every
// subscriber would reject it anyway, so log loudly here rather than let
// the invalidation vanish downstream. publish() stays never-throw: a
// failed best-effort invalidation must not fail the caller's write.
logError(
'[cachekit] Failed to serialize invalidation event:',
err instanceof Error ? err.message : String(err)
);
return;
}

// Fire-and-forget - don't await, don't throw
this.redis.publish(this.channelName, Buffer.from(data)).catch((err) => {
Expand Down
5 changes: 4 additions & 1 deletion packages/cachekit/src/serialization/interop.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { decode as msgpackDecode } from '@msgpack/msgpack';
import { blake2b } from '@noble/hashes/blake2.js';
import { bytesToHex } from '@noble/hashes/utils.js';
import { ConfigurationError, SerializationError, ValueTooLargeError } from '../errors.js';
import { boundedDecodeOptions } from './serializer.js';
import { assertDecodeDepth, boundedDecodeOptions } from './serializer.js';
import {
DEFAULT_MAX_ENCODED_SIZE,
DEFAULT_MAX_DECODED_SIZE,
Expand Down Expand Up @@ -597,6 +597,9 @@ export function decodeInteropValue<T>(data: Uint8Array): T {
`Input size ${data.length} exceeds max ${DEFAULT_MAX_DECODED_SIZE}`
);
}
// Bound nesting depth before the decoder eagerly preallocates per-header
// collections (LAB-2487, full rationale: assertDecodeDepth in serializer.ts).
assertDecodeDepth(data, DEFAULT_MAX_DEPTH);
let decoded: unknown;
try {
// Backend bytes are untrusted — bound header preallocation (full
Expand Down
153 changes: 150 additions & 3 deletions packages/cachekit/src/serialization/serializer.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { describe, it, expect } from 'vitest';
import { MessagePackSerializer } from './serializer.js';
import { decode as msgpackDecode, encode as msgpackEncode } from '@msgpack/msgpack';
import { MessagePackSerializer, assertDecodeDepth, boundedDecodeOptions } from './serializer.js';
import { ValueTooLargeError, SerializationError } from '../errors.js';

describe('MessagePackSerializer', () => {
Expand Down Expand Up @@ -186,9 +187,13 @@ describe('MessagePackSerializer', () => {
});

it('wraps decode errors with cause', () => {
const invalidData = new Uint8Array([0xff, 0xff, 0xff]);
// Structurally valid msgpack that the pre-scan passes (fixarray of 3,
// depth 1, no trailing bytes) but the decoder rejects on the collection
// cap — exercises the decode()-path error wrapping, not the pre-scan.
const small = new MessagePackSerializer({ maxCollectionSize: 2 });
const overCap = Uint8Array.of(0x93, 0x01, 0x02, 0x03);
try {
serializer.decode(invalidData);
small.decode(overCap);
expect.fail('Should have thrown');
} catch (error) {
expect(error).toBeInstanceOf(SerializationError);
Expand Down Expand Up @@ -287,4 +292,146 @@ describe('MessagePackSerializer', () => {
expect(() => serializer.encode(largeMap)).toThrow(SerializationError);
});
});

describe('LAB-2487: DoS protection - nested collection-header amplification', () => {
const serializer = new MessagePackSerializer();

// Build N nested `array16` headers each claiming 10000 elements (3 bytes
// each). Un-mitigated this forced ~400MB of transient heap from ~15KB
// (~26,700x) before the end-of-input throw, because @msgpack/msgpack runs
// `new Array(size)` per header before children decode.
const nestedArray16Headers = (n: number): Uint8Array => {
const buf = new Uint8Array(n * 3);
for (let i = 0; i < n; i++) {
buf[i * 3] = 0xdc; // array16
buf[i * 3 + 1] = 0x27; // 0x2710 = 10000
buf[i * 3 + 2] = 0x10;
}
return buf;
};

it('rejects the 5000-deep forged probe before the decoder allocates (AC-2)', () => {
// The pre-scan fails on depth (or truncation) after reading only headers,
// so `decode()` — and its per-header `new Array(10000)` — never runs. The
// structural rejection is what pins the allocation ceiling: no decode, no
// preallocation. Un-mitigated, decode() of this input reached ~400MB.
expect(() => serializer.decode(nestedArray16Headers(5000))).toThrow(SerializationError);
expect(() => assertDecodeDepth(nestedArray16Headers(5000), 100)).toThrow(/depth|Truncated/);
});

it('rejects a spine that claims more children than the bytes can back', () => {
// 100 nested array16(10000) = 300 bytes claiming 10000 children per level.
// Structural completeness (global slot budget) rejects it as truncated:
// the buffer cannot back the declared children. Depth alone would pass.
expect(() => assertDecodeDepth(nestedArray16Headers(100), 1000)).toThrow(SerializationError);
});

it('enforces the depth bound at the configured maxDepth', () => {
const shallow = new MessagePackSerializer({ maxDepth: 3 });
let v: unknown = 1;
for (let i = 0; i < 4; i++) v = [v]; // 4 levels of nesting
const buf = serializer.encode(v); // default serializer encodes fine (maxDepth 100)
expect(() => shallow.decode(buf)).toThrow(/depth/);
});

it('never rejects a legal payload nested up to maxDepth (write/read symmetry)', () => {
// A value wrapped in exactly maxDepth collections must still round-trip:
// the pre-scan must be no stricter than the encoder's own depth bound.
let v: unknown = 42;
for (let i = 0; i < 99; i++) v = [v]; // 99 array levels, well within 100
expect(serializer.decode(serializer.encode(v))).toEqual(v);

// Wide-but-shallow and mixed structures must pass untouched.
const wide = { list: Array.from({ length: 5000 }, (_, i) => i), meta: { a: true, b: 'x' } };
expect(serializer.decode(serializer.encode(wide))).toEqual(wide);
});

it('differential fuzz: pre-scan is byte-faithful to the decoder across every type', () => {
// The pre-scan is a second parser gating the real decoder; the one
// catastrophic desync is a width miscount that shifts every later offset.
// Encode random legal values spanning EVERY msgpack head-byte family
// (incl. bin, bigint→int64, float64, ext→timestamp, and collections wide
// enough to emit array16/map16, not just fixarray/fixmap) and assert the
// pre-scan accepts exactly what the decoder accepts — proving no
// skip-width desync. `useBigInt64` matches the interop decode path.
const encOpts = { useBigInt64: true } as const;
const opts = { ...boundedDecodeOptions(10000, 10 * 1024 * 1024), useBigInt64: true };
let seed = 0x2487;
const rand = () => {
// deterministic LCG; Math.imul avoids the 2^53 overflow trap
seed = (Math.imul(seed, 1103515245) + 12345) & 0x7fffffff;
return seed / 0x7fffffff;
};
const scalar = (): unknown => {
const r = rand();
if (r < 0.14) return null;
if (r < 0.28) return Math.floor(rand() * 1e9); // int
if (r < 0.42) return rand() * 1e6 + 0.5; // float64
if (r < 0.56) return BigInt(Math.floor(rand() * 1e15)); // int64
if (r < 0.7) return rand() < 0.5;
if (r < 0.84) return 'k'.repeat(Math.floor(rand() * 40)); // fixstr/str8
if (r < 0.92) return new Uint8Array(Math.floor(rand() * 30)); // bin8
return new Date(Math.floor(rand() * 2e12)); // ext (timestamp)
};
const randomValue = (depth: number): unknown => {
const r = rand();
if (depth > 4 || r < 0.45) return scalar();
// Occasionally emit a WIDE array/map of scalars so array16/map16 headers
// (>15 entries) get exercised — without recursing, so total node count
// stays bounded (deep recursion keeps a small branching factor).
if (r < 0.55) {
const wide = 16 + Math.floor(rand() * 24); // 16..39 → array16/map16
if (rand() < 0.5) return Array.from({ length: wide }, () => scalar());
const o: Record<string, unknown> = {};
for (let i = 0; i < wide; i++) o['f' + i] = scalar();
return o;
}
const n = Math.floor(rand() * 5); // narrow recursion (fixarray/fixmap)
if (r < 0.8) return Array.from({ length: n }, () => randomValue(depth + 1));
const o: Record<string, unknown> = {};
for (let i = 0; i < n; i++) o['f' + i] = randomValue(depth + 1);
return o;
};

for (let i = 0; i < 500; i++) {
const bytes = msgpackEncode(randomValue(0), encOpts);
// Legal values must pass the pre-scan and round-trip through the decoder.
expect(() => assertDecodeDepth(bytes, 100)).not.toThrow();
expect(() => msgpackDecode(bytes, opts)).not.toThrow();
}

// Random garbage: the pre-scan must either accept or reject through
// SerializationError — never fault with a raw RangeError/TypeError (a bad
// skip width or out-of-range DataView read, i.e. a walker bug). We do NOT
// assert decode never throws on accepted garbage — a structurally-complete
// buffer can still be a malformed ext/invalid-UTF-8 str the decoder
// rejects, which is the SAFE desync direction (reject, not over-allocate).
// The bounded opts here mean no false-accept can amplify regardless; the
// dedicated nested-header test above pins the actual amplification bound.
for (let i = 0; i < 1000; i++) {
const bytes = new Uint8Array(Math.floor(rand() * 48));
for (let j = 0; j < bytes.length; j++) bytes[j] = Math.floor(rand() * 256);
try {
assertDecodeDepth(bytes, 100);
} catch (error) {
// A non-SerializationError means the walker itself faulted, not a
// structural rejection.
expect(error).toBeInstanceOf(SerializationError);
}
}
});

it('directly exercises each pre-scan rejection branch', () => {
// Invalid/reserved head byte (0xc1) → default throw.
expect(() => assertDecodeDepth(Uint8Array.of(0xc1), 100)).toThrow(/head byte/);
// Trailing bytes after a complete value.
expect(() => assertDecodeDepth(Uint8Array.of(0x2a, 0x2a), 100)).toThrow(/Trailing/);
// Truncated multibyte length (str16 header claims 2 length bytes, only 1).
expect(() => assertDecodeDepth(Uint8Array.of(0xda, 0x00), 100)).toThrow(/Truncated/);
// Truncated collection children (fixarray(1) with no element).
expect(() => assertDecodeDepth(Uint8Array.of(0x91), 100)).toThrow(/Truncated/);
// Empty buffer is not a valid single value.
expect(() => assertDecodeDepth(new Uint8Array(0), 100)).toThrow(/Truncated/);
});
});
});
Loading
Loading