Skip to content

fix(serializers): bound untrusted msgpack decode depth and header allocation (LAB-2503) - #276

Open
27Bslash6 wants to merge 4 commits into
mainfrom
lab-2503-decode-bounds
Open

fix(serializers): bound untrusted msgpack decode depth and header allocation (LAB-2503)#276
27Bslash6 wants to merge 4 commits into
mainfrom
lab-2503-decode-bounds

Conversation

@27Bslash6

@27Bslash6 27Bslash6 commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

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 the array16(10000) probe — with array32 headers claiming len(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) in serializers/base.py, now the only msgpack.unpackb call site (auto ×11, standard, decode_interop_value):

  1. Zero-copy structural walk firstcheck_msgpack_structure(data, MSGPACK_MAX_NESTING) in the Rust extension (rust/src/msgpack_bounds.rs, opcode table mirrors cachekit-rs check_structure). Header-only: str/bin/ext payloads are skipped by offset, the input is borrowed in place (bytes, or the read-only memoryview-of-bytes the read path carries), and the only allocation is one u64 per 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 KB array16(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 raise ValueError naming 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.
  2. Explicit max_*_len=len(data) on unpackb — 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): AutoSerializer fail-open — when a checksum-verified ByteStorage envelope's payload failed to decode, the except Exception fallback re-decoded the envelope bytes as plain MessagePack and returned its positional fields as the cached value (the LAB-1765 class of bug). Now raises SerializationError. 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 Exception clauses in AutoSerializer.deserialize now catch PAYLOAD_DECODE_ERRORS (ValueError, TypeError, KeyError, AttributeError, OverflowError, BufferError — defined once in base.py, shared with StandardSerializer), 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(), whose feed() 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's decode-bounds.json (vendored, sha-pinned) run through 7 decode paths — unpackb_bounded, interop, standard plain/envelope, auto plain/envelope, and CacheSerializationHandler.deserialize_data on a forged CK v3 frame — asserting rejection as ValueError/SerializationError with 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 → OverflowError inside the object hook) is a SerializationError on both the plain and verified-envelope paths. Full unit + critical suite green (2286); tests/performance/test_large_object_memory.py 8/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 StackError message, 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 the OverflowError/TypeError gaps the exception narrowing exposed (fixed + pinned); craftsman/catchphrase: pure walk moved out of the FFI file, PAYLOAD_DECODE_ERRORS centralised, stale StackError-era comments rewritten, BytesView folded to two variants, unreachable UnpackException dropped. Deferred with tickets: ByteStorage.retrieve error typing (checksum mismatch vs not-an-envelope both raise ValueError), 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_bounded docstring is the canonical rationale (doctest-executed), mechanism documented on check_msgpack_structure in rust/src/msgpack_bounds.rs. Protocol spec/vectors: cachekit-io/protocol#59. Sibling: cachekit-io/cachekit-rs#73.

…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.
@coderabbitai

coderabbitai Bot commented Sep 2, 2026

Copy link
Copy Markdown

Important

Approval pending

CodeRabbit 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.

  • 🔍 Trigger review

Comment @coderabbitai help to get the list of available commands.

@kodus-27b

This comment has been minimized.

Comment thread src/cachekit/serializers/auto_serializer.py Outdated
Comment thread src/cachekit/serializers/base.py Outdated
Comment thread src/cachekit/serializers/base.py
Comment thread tests/unit/protocol/test_decode_bounds.py

@kodus-27b kodus-27b Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Found critical issues please review the requested changes

@codecov

codecov Bot commented Sep 2, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 71.11111% with 13 lines in your changes missing coverage. Please review.
✅ All tests successful. No failed tests found.

Files with missing lines Patch % Lines
src/cachekit/serializers/auto_serializer.py 59.37% 11 Missing and 2 partials ⚠️

📢 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.
@27Bslash6

Copy link
Copy Markdown
Contributor Author

@kody start-review

@kodus-27b

kodus-27b Bot commented Sep 3, 2026

Copy link
Copy Markdown

Code Review Completed! 🔥

The code review was successfully completed based on your current configurations.

Kody Guide: Usage and Configuration
Interacting with Kody
  • Request a Review: Ask Kody to review your PR manually by adding a comment with the @kody start-review command at the root of your PR.

  • Validate Business Logic: Ask Kody to validate your code against business rules by adding a comment with the @kody -v business-logic command.

  • Provide Feedback: Help Kody learn and improve by reacting to its comments with a 👍 for helpful suggestions or a 👎 if improvements are needed.

Current Kody Configuration
Review Options

The following review options are enabled or disabled:

Options Enabled
Bug
Performance
Security
Business Logic

Access your configuration settings here.

Comment on lines +743 to 745
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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

kody code-review Bug medium

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 e
Prompt 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.

Comment on lines +743 to 745
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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

kody code-review Bug medium

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 e
Prompt 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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant