fix(serializers): bound untrusted msgpack decode depth and header allocation (LAB-2503) - #276
fix(serializers): bound untrusted msgpack decode depth and header allocation (LAB-2503)#27627Bslash6 wants to merge 4 commits into
Conversation
…ocation (LAB-2503) All four backend-bytes decode sites (auto, standard, interop, and the DataFrame/Series branches) now go through unpackb_bounded: a header-only Unpacker.skip() walk first (allocation-free, ~1/4 the cost of decode) rejects nesting past the pinned 1024 ceiling and any header claiming more than the input can back, then unpackb runs with every max_*_len passed explicitly. Before: msgpack-python's defaults allowed ~8 x 1024 x len(input) bytes of transient heap (measured 10 KB -> 67 MB). Also fail closed when a checksum-verified envelope carries an undecodable payload: AutoSerializer used to fall through and return the ENVELOPE's positional fields as the cached value. Regression-guarded by the protocol decode-bounds vectors on every path.
- StackError carries an empty message: normalise depth rejections to a ValueError naming MSGPACK_MAX_NESTING (StandardSerializer previously reported 'Failed to deserialize MessagePack data: ' with nothing after). - AutoSerializer's plain path no longer hides the decode-bound rejection behind the NumPy header error: the final SerializationError carries the envelope, msgpack and numpy reasons. - Docstring stops selling the explicit max_*_len caps as an independent bound (unreachable once the walk passes; defence in depth) and records the +1x transient copy Unpacker.feed costs. - Vendored vectors re-synced (array16/map16 bombs now claim 2000 < len so they discriminate for msgpack-python); redundant SDK-local tests cut.
|
Important Approval pendingCodeRabbit has no unresolved comments, but it has not reviewed the latest commit. Use the checkbox below to review the latest commit. CodeRabbit will approve the changes if it finds no blocking issues.
Comment |
This comment has been minimized.
This comment has been minimized.
Codecov Report❌ Patch coverage is
📢 Thoughts on this report? Let us know! |
…bound (LAB-2503) unpackb_bounded ran the structural check with msgpack.Unpacker.skip(), and Unpacker.feed() copies the whole input into its buffer first: a +1x transient on every cache read, which is what tripped the File-backend 3.5x allocation bound (4.00x) in CI. The walk now lives in the Rust extension as check_msgpack_structure: header-only, str/bin/ext payloads skipped by offset, zero-copy for bytes and for the read-only memoryview-of-bytes the read path carries, one u64 per open collection. It also tracks the global element budget (pending elements <= remaining bytes) alongside depth, so a 15 KB array16(2000) bomb is rejected at depth 8 instead of after a 1024-level walk. Measured: walk is 2-13% of decode time on collection-heavy payloads, ~0 on a 50 MiB bin, 0 B Python-heap peak. retrieve() and the walk share one bytes_view() borrow helper so the containment proof is written once. Kody: the broad excepts in AutoSerializer.deserialize now catch one named tuple of decode failures (_PAYLOAD_DECODE_ERRORS); RuntimeError for a missing optional dependency bubbles instead of reading as a corrupt entry.
- Move the pure check_msgpack_structure into rust/src/msgpack_bounds.rs (not gated on the python feature) and stop the crate headers claiming all logic lives in cachekit-core. - PAYLOAD_DECODE_ERRORS now lives in serializers/base.py beside the function that raises them and is shared by AutoSerializer and StandardSerializer. Adds OverflowError (np.frombuffer on a forged ndarray itemsize escaped deserialize as a bare exception — reproduced) and BufferError (non-u8 exporter at the PyO3 boundary, LAB-770); drops UnpackException, which unpackb never raises. _deserialize_numpy also catches the TypeError a forged dtype string produces. - BytesView folded to Borrowed/Owned: a bytes object is a window at offset 0. - MSGPACK_MAX_NESTING comment and the at-bound test comment now say what the constant is (cachekit's ceiling enforced by the walk, bounded above by the C unpacker stack) instead of the pre-walk StackError story. - Regression test: a forged ndarray payload is a SerializationError on both the plain and verified-envelope paths.
|
@kody start-review |
Code Review Completed! 🔥The code review was successfully completed based on your current configurations. Kody Guide: Usage and ConfigurationInteracting with Kody
Current Kody ConfigurationReview OptionsThe following review options are enabled or disabled:
|
| except (ValueError, TypeError, IndexError) as e: | ||
| # TypeError: np.frombuffer on a forged dtype string; UnicodeDecodeError is a ValueError. | ||
| raise SerializationError(f"Failed to deserialize NumPy array: {e}") from e |
There was a problem hiding this comment.
WHAT: _deserialize_numpy catches only (ValueError, TypeError, IndexError), but np.frombuffer with the attacker-controlled dtype_str decoded at line 721 raises OverflowError on a forged/huge itemsize, which escapes this handler. WHY: the two NUMPY_RAW entry points at deserialize() lines 523 and 532 call _deserialize_numpy via a bare return with no surrounding try/except, so a forged cache entry leaks a raw OverflowError out of deserialize() instead of the contracted SerializationError — the PR added OverflowError to PAYLOAD_DECODE_ERRORS for exactly this reason but did not update this inner catch. HOW: add OverflowError to the except tuple.
except (ValueError, TypeError, IndexError, OverflowError) as e:
# TypeError: np.frombuffer on a forged dtype string; OverflowError: forged itemsize past C long;
# UnicodeDecodeError is a ValueError.
raise SerializationError(f"Failed to deserialize NumPy array: {e}") from ePrompt for LLM
File src/cachekit/serializers/auto_serializer.py:
Line 743 to 745:
WHAT: _deserialize_numpy catches only (ValueError, TypeError, IndexError), but np.frombuffer with the attacker-controlled dtype_str decoded at line 721 raises OverflowError on a forged/huge itemsize, which escapes this handler. WHY: the two NUMPY_RAW entry points at deserialize() lines 523 and 532 call _deserialize_numpy via a bare return with no surrounding try/except, so a forged cache entry leaks a raw OverflowError out of deserialize() instead of the contracted SerializationError — the PR added OverflowError to PAYLOAD_DECODE_ERRORS for exactly this reason but did not update this inner catch. HOW: add OverflowError to the except tuple.
Suggested Code:
except (ValueError, TypeError, IndexError, OverflowError) as e:
# TypeError: np.frombuffer on a forged dtype string; OverflowError: forged itemsize past C long;
# UnicodeDecodeError is a ValueError.
raise SerializationError(f"Failed to deserialize NumPy array: {e}") from e
Talk to Kody by mentioning @kody
Was this suggestion helpful? React with 👍 or 👎 to help Kody learn from this interaction.
| except (ValueError, TypeError, IndexError) as e: | ||
| # TypeError: np.frombuffer on a forged dtype string; UnicodeDecodeError is a ValueError. | ||
| raise SerializationError(f"Failed to deserialize NumPy array: {e}") from e |
There was a problem hiding this comment.
WHAT: _deserialize_numpy catches only (ValueError, TypeError, IndexError) but np.dtype/np.frombuffer/reshape on a forged NUMPY_RAW entry (e.g. a void dtype string with an oversized itemsize, or an overflowing element count) raises OverflowError, which this PR deliberately added to PAYLOAD_DECODE_ERRORS as a forged-payload signal. WHY: _deserialize_numpy is invoked directly at lines 523 and 532 with no surrounding handler that normalizes PAYLOAD_DECODE_ERRORS, so an uncaught OverflowError propagates out of deserialize() as a raw exception instead of SerializationError, breaking the serializer's exception contract for forged cache entries on the NUMPY_RAW path (only the msgpack-fallback path at 641-642 masks it). HOW: include OverflowError in the inner catch so every forged-numpy failure fails closed as SerializationError regardless of entry point.
except (ValueError, TypeError, IndexError, OverflowError) as e:
# TypeError: np.frombuffer on a forged dtype string; OverflowError: forged itemsize/count;
# UnicodeDecodeError is a ValueError.
raise SerializationError(f"Failed to deserialize NumPy array: {e}") from ePrompt for LLM
File src/cachekit/serializers/auto_serializer.py:
Line 743 to 745:
WHAT: `_deserialize_numpy` catches only `(ValueError, TypeError, IndexError)` but `np.dtype`/`np.frombuffer`/`reshape` on a forged NUMPY_RAW entry (e.g. a void dtype string with an oversized itemsize, or an overflowing element count) raises `OverflowError`, which this PR deliberately added to `PAYLOAD_DECODE_ERRORS` as a forged-payload signal. WHY: `_deserialize_numpy` is invoked directly at lines 523 and 532 with no surrounding handler that normalizes `PAYLOAD_DECODE_ERRORS`, so an uncaught `OverflowError` propagates out of `deserialize()` as a raw exception instead of `SerializationError`, breaking the serializer's exception contract for forged cache entries on the NUMPY_RAW path (only the msgpack-fallback path at 641-642 masks it). HOW: include `OverflowError` in the inner catch so every forged-numpy failure fails closed as `SerializationError` regardless of entry point.
Suggested Code:
except (ValueError, TypeError, IndexError, OverflowError) as e:
# TypeError: np.frombuffer on a forged dtype string; OverflowError: forged itemsize/count;
# UnicodeDecodeError is a ValueError.
raise SerializationError(f"Failed to deserialize NumPy array: {e}") from e
Talk to Kody by mentioning @kody
Was this suggestion helpful? React with 👍 or 👎 to help Kody learn from this interaction.
What & why (LAB-2503)
Every cache read decodes MessagePack bytes the backend controls. msgpack-python's C unpacker pre-allocates each container (
PyList_New(n)) before decoding its children, and nested headers stack those allocations depth-first. The ticket assumed an "82 MB hard ceiling"; that was an artifact of thearray16(10000)probe — witharray32headers claiminglen(input)the library defaults allow ~8 × 1024 × len(input): 10 KB → 67 MB measured, linear in input, and N concurrent poisoned reads multiply it.The fix
unpackb_bounded(data, **opts)inserializers/base.py, now the onlymsgpack.unpackbcall site (auto ×11, standard,decode_interop_value):check_msgpack_structure(data, MSGPACK_MAX_NESTING)in the Rust extension (rust/src/msgpack_bounds.rs, opcode table mirrors cachekit-rscheck_structure). Header-only: str/bin/ext payloads are skipped by offset, the input is borrowed in place (bytes, or the read-onlymemoryview-of-bytesthe read path carries), and the only allocation is oneu64per open collection. Rejects nesting past 1024 (MSGPACK_MAX_NESTING, cachekit's own ceiling, bounded above by the C unpacker's stack) and any point where the elements still owed by open headers exceed the remaining input — so a 15 KBarray16(2000)spine is rejected at the 8th header, not after a 1024-level descent. Every element that survives is backed by ≥ 1 byte, so the real decode's total pre-allocation is bounded by len(data) rather than depth × declared length. Rejections raiseValueErrornaming the bound; all read paths already turn that into a controlled cache miss. Measured: 2–13 % of decode time on collection-heavy payloads (1M ints: 2.1 ms vs 29.3 ms), ~0 on a 50 MiB bin, 0 B Python-heap peak.max_*_len=len(data)onunpackb— unreachable once the walk passes; defence in depth against a walk regression, documented as such.Also fixed on the way (found by the new test):
AutoSerializerfail-open — when a checksum-verified ByteStorage envelope's payload failed to decode, theexcept Exceptionfallback re-decoded the envelope bytes as plain MessagePack and returned its positional fields as the cached value (the LAB-1765 class of bug). Now raisesSerializationError. The plain path's final error now carries the envelope/msgpack/numpy reasons instead of surfacing only "expected NUMPY_RAW header".Exception contract: the broad
except Exceptionclauses inAutoSerializer.deserializenow catchPAYLOAD_DECODE_ERRORS(ValueError,TypeError,KeyError,AttributeError,OverflowError,BufferError— defined once inbase.py, shared withStandardSerializer), so a missing optional dependency (RuntimeError) bubbles instead of reading as a corrupt entry.History: the first version of the walk used
msgpack.Unpacker(...).skip(), whosefeed()copies the input — that +1× transient tripped the File-backend 3.5× allocation bound in CI (4.00×). The Rust walk replaced it; the bound passes.Tests
tests/unit/protocol/test_decode_bounds.py: the protocol'sdecode-bounds.json(vendored, sha-pinned) run through 7 decode paths —unpackb_bounded, interop, standard plain/envelope, auto plain/envelope, andCacheSerializationHandler.deserialize_dataon a forged CK v3 frame — asserting rejection asValueError/SerializationErrorwith tracemalloc peak < 2 MiB + 4×input on every reject vector, decode on every accept vector, the 1024/1025 nesting boundary, and trailing-byte rejection.tests/unit/test_auto_serializer_new_types.py: a forged ndarray payload (itemsize past C long →OverflowErrorinside the object hook) is aSerializationErroron both the plain and verified-envelope paths. Full unit + critical suite green (2286);tests/performance/test_large_object_memory.py8/8 including the previously red File-backend bound.Review
Round 1 (skip-based walk), expert panel at critical stakes: security NO FINDINGS; craftsman/bug-hunter findings applied (empty
StackErrormessage, hidden decode error behind the NumPy fallback, dishonest "two bounds" docstring, feed-copy cost recorded); catchphrase cuts applied.Round 2 (Rust walk), same panel: security NO FINDINGS after 200k fuzz probes (no abort under
panic=abort, no walker/decoder desync vs msgpack-python 1.2.1 across all 256 markers, depth boundary matches the C unpacker exactly); bug-hunter found theOverflowError/TypeErrorgaps the exception narrowing exposed (fixed + pinned); craftsman/catchphrase: pure walk moved out of the FFI file,PAYLOAD_DECODE_ERRORScentralised, stale StackError-era comments rewritten,BytesViewfolded to two variants, unreachableUnpackExceptiondropped. Deferred with tickets: ByteStorage.retrieve error typing (checksum mismatch vs not-an-envelope both raiseValueError), duplicated DataFrame/Series integrity-on branches, core-shared zero-copy walk for py/rs/wasm (this PR ships the py-local one).Docs
README "Production Hardened" bullet;
unpackb_boundeddocstring is the canonical rationale (doctest-executed), mechanism documented oncheck_msgpack_structureinrust/src/msgpack_bounds.rs. Protocol spec/vectors: cachekit-io/protocol#59. Sibling: cachekit-io/cachekit-rs#73.