Repository navigation
Skip: advancing past values without decoding #65
Replies: 3 comments
|
Container depth limits have been implemented in PR #72. The streaming decoder now tracks nesting depth internally and enforces a configurable |
Review findingsOverallThe problem statement is well-motivated, the byte count table is accurate against the msgpack spec, and the two streaming approaches are analyzed correctly — Approach A (peek-based pre-scan) is the right choice, and Approach B is correctly rejected because partial consumption on failure violates the all-or-nothing contract. The Five findings, one significant. 1. Stack overflow in recursive
|
Uh oh!
There was an error while loading. Please reload this page.
The problem
When decoding a msgpack map, the caller reads the header (element count), then must read every key-value pair sequentially to advance the reader to the correct position for whatever follows the map. If the caller encounters a key it doesn't recognize — because the sender is running a newer protocol version, or because the field is irrelevant to this consumer — it still must decode the value to advance past it. But it can't call the right decode method without knowing the value's type.
The caller is stuck: they can't decode the value (unknown type), they can't skip it (no such method exists), and they can't stop reading (the map header committed them to N pairs). The only options are to error out or to use the streaming decoder's
next()method to decode into aMessagePackValueunion — which works but allocates memory for data that will be immediately discarded.This problem isn't limited to maps. An array might contain elements of mixed types where the caller only cares about some positions. Or a protocol might prepend metadata headers that some consumers should ignore.
Forward compatibility
Skip is the foundation for forward-compatible protocols. Without it, adding a field to a msgpack map is a breaking change — every consumer must be updated simultaneously to decode the new field's type. With skip, consumers ignore fields they don't understand and continue processing the rest of the message. This is the same principle that makes JSON-based protocols tolerant of new fields.
Example
A versioned protocol sends maps with known fields. A newer sender adds a
priorityfield:How other libraries handle this
vmihailenco/msgpack (Go)
Decoder.Skip()is a single method call that handles any value type, including nested containers. It reads the format byte, determines the type, and either advances past fixed-size data or recursively skips container elements. Returns an error if the data is malformed.MessagePack-CSharp
MessagePackReader.Skip()advances past one complete value. For containers, it recursively skips all contained elements. The reader is a struct, so skip operates on the same position-tracking state as normal reads.msgpack-python
Unpacker.skip()skips the next value in the stream. It internally reads and discards the value. Some implementations optimize by not constructing intermediate objects.msgpack-java (Zeebe variant)
MsgPackReader.skipValue()reads the format byte, determines the value's byte count (recursively for containers), and advances the reader position.Common pattern
All implementations provide a single call that handles every format type including nested containers. The recursion is internal — callers never need to handle container skipping themselves. The format byte determines the skip strategy:
Design
Byte counts by format
For skip to work, it must know the total byte count for every format. The msgpack spec makes this deterministic:
For flat values, the total byte count is deterministic from the header alone. For containers, the total byte count requires examining every contained element — msgpack container headers store element counts, not byte counts.
Timestamps are transparently handled because they are wire-encoded as ext formats (timestamp_32 is fixext_4, timestamp_64 is fixext_8, timestamp_96 is ext_8). Skip operates at the wire format level and does not need to interpret ext semantics.
The container recursion challenge
Containers make skip fundamentally different from other decode operations. Skipping a flat value (string, binary, integer) is O(1) — peek at the header, compute the byte count, advance. Skipping a container is O(n) where n is the total number of values in the container tree, because each element's format byte must be examined to determine its size.
This is unavoidable — the msgpack format does not store total byte counts for containers. Every skip implementation in every library does this recursive walk.
Which APIs get skip
MessagePackDecoder: Askipmethod on the low-level decoder is the natural primitive. It takes aReader refand advances past one complete value, recursing for containers. Uses?for errors (same as all other decoder methods). This is the building block.MessagePackStreamingDecoder: Askipmethod on the streaming decoder provides the streaming-safe variant. It must confirm all bytes are available before consuming any, consistent with the decoder's contract thatNotEnoughDatameans zero bytes consumed.Streaming decoder: the all-or-nothing problem
The streaming decoder's contract is: either decode completely, or consume nothing and return
NotEnoughData. For flat values, this is easy — peek at the header, checkreader.size(), proceed or return. For containers, the total byte count isn't known until every element has been examined.Two approaches:
Approach A: Peek-based pre-scan
Calculate the total byte count using only peek operations (no consuming), then check if enough data is available, then advance past all bytes at once using
Reader.skip(n)(which advances the cursor without allocating).The reader supports
peek_u8(offset),peek_u16_be(offset), andpeek_u32_be(offset)at arbitrary offsets. A recursive function walks the structure using peeks to compute the total byte count:The streaming decoder calls
_skip_size(reader, 0)?to get the total byte count. If any peek fails (data not yet available), the function errors and the streaming decoder returnsNotEnoughData. On success, it advances past the bytes withreader.skip(total_size)?.This approach maintains the all-or-nothing contract cleanly. The
_skip_sizefunction is a pure calculation (peek-only, no consumption), whileskipis the consumer. The same_skip_sizehelper could be reused by bothMessagePackDecoderandMessagePackStreamingDecoder.The cost is that the peek pass walks every element in the skipped value. For flat values this is negligible (one peek for the format byte, possibly one more for the length prefix). For large nested containers, the walk visits every contained element. This is unavoidable given the msgpack format.
Approach B: Delegate to
MessagePackDecoder.skipThe streaming decoder already confirms data availability before delegating to
MessagePackDecoderfor all other operations. For flat values, this is straightforward — peek at the header to compute the byte count, check size, delegate.For containers, the streaming decoder could delegate the recursive skip to
MessagePackDecoder.skip(reader)?. But this breaks the all-or-nothing contract: ifMessagePackDecoder.skipsucceeds for the first 50 elements of an array but fails on element 51 (not enough data), 50 elements have already been consumed. The reader is now in a corrupted state — the same problem the streaming decoder was designed to prevent.This approach only works for flat values. For containers, it would need to fall back to Approach A's pre-scan, making it a hybrid that's more complex without clear benefit.
Interaction with size limits (discussion #64)
If decoding size limits are implemented, skip needs a policy for how it interacts with them:
Option 1: Skip respects limits. If a value's declared size exceeds the configured limit, skip returns
LimitExceededinstead of advancing. This prevents an attacker from using skip as a vector to force the decoder to walk past huge payloads. But it partially defeats skip's purpose — if a forward-compatible decoder skips unknown fields, a large unknown field would halt processing even though the data is being discarded.Option 2: Skip ignores limits. Skip advances past any value regardless of size limits. The rationale is that skip doesn't allocate or return the data — it just advances the reader. The memory was already consumed at the buffering layer. Limits exist to prevent the caller from receiving oversized values, which skip doesn't do.
Option 3: Skip has its own limits. A separate
max_skip_lenormax_skip_depthlimit controls how much skip is willing to traverse. This provides DoS protection specific to skip's threat model (recursive container amplification, walking past huge flat values) without coupling it to the decode limits.The choice depends on the threat model. For flat values, skipping 4 GB of string data means the reader must have 4 GB buffered — the memory cost is the same whether you decode or skip. For containers, the threat is CPU cost: recursively walking a deeply nested or very wide container structure. Container depth limits (noted as future work in discussion #64) are relevant here.
Return type
For
MessagePackDecoder:Partial, consistent with all other decoder methods. Errors if insufficient data or invalid format byte.
For
MessagePackStreamingDecoder, a dedicated return type avoids confusion withDecodeResult(which includesMessagePackValuevariants that don't apply to skip):Nonemeans the value was successfully skipped. This is unambiguous becauseSkipResultis a separate type fromDecodeResult— there's no confusion with "decoded a nil value."If size limits apply to skip, add
LimitExceeded:Scope
This proposal covers:
skipmethod onMessagePackDecoderfor the non-streaming caseskipmethod onMessagePackStreamingDecoderfor the streaming-safe caseThis proposal does not cover:
All reactions