Decoder policy and security limits

View Source

This document defines the decoder's public option contract, recursive charging model, deterministic profile, and stable error behavior. Limits are explicit policies supplied by the caller. They are not estimates of physical RAM or of memory safely available to an AtomVM process.

Option representation and operation scope

Public options are proplists. The library validates them and normalizes them once into fixed-shape internal records before decoding. Recursive functions use constant-time record fields; they do not search, rebuild, or convert proplists. Duplicate options are last-wins.

Each public call is one operation. decode_all/1,2 and decode_sequence/1,2 retain the same state across all top-level items. partial_decode/1,2 measures the complete represented item with one state, and partial_deep_decode/1 performs a separately bounded deep-decode operation using the normalized policy stored in the validated opaque descriptor.

Global node/work budget

max_items is a positive global node/work budget, not a per-container length. The decoder charges each consumed node. Definite declarations must authorize their direct-child allocation against the remaining budget before recursive descent:

  • one node for every scalar or definite string value;
  • one node for every array or map container;
  • one node for each array element;
  • map keys and values independently;
  • one node for a tag and another for its tagged value;
  • one node for every indefinite string chunk;
  • one node for every top-level sequence value.

Nested containers and later sequence items never reset the budget. For example, [[1,2],[3,4]] consumes seven nodes: the outer array, two inner arrays, and four integers. A budget of six fails with {error, {max_items_exceeded, 6}}; seven succeeds.

The small-integer top-level sequence fast path carries the decremented fixed-shape state after every value; it does not defer or batch budget updates.

Declared definite array/map lengths are checked against the remaining node budget before child allocation or descent. Arrays require at least one remaining node per declared child; maps require two per pair. The recursive decoder still charges each child at consumption time.

Fixed-cost definite-container optimization

Physical ESP32-S3 evidence showed that the general synchronous decoder spent more than eight seconds dispatching the exact 4096-node flat integer array. The idle task could not run during that single call, so the hardware watchdog fired. The selected remedy is algorithmic: large definite arrays and non-deterministic maps containing only preferred one-byte unsigned integers use the pinned AtomVM native erlang:binary_to_list/1 implementation after admission checks and accept its result only after byte validation. Every other container uses the unchanged general bounded loop. This removes repeated option decoding, argument decoding, and state-record rebuilding for a common fixed-cost subset. It adds no sleep, yield, scheduler call, watchdog change, or PSRAM-dependent behavior.

The optimization is guarded by these invariants:

  1. The public entry point checks the complete input against max_bytes before parsing.
  2. The container node is charged normally. Its declared number of direct children must fit both the remaining max_items budget and the remaining input before the optimized loop begins. The fast path repeats the byte-size guard because the general parser may defer an insufficient-input result to preserve a more specific reserved-header or unexpected-break error.
  3. Only preferred one-byte unsigned integers (00-17) use the native path. Each consumes exactly one input byte and exactly one node. The already-proven child budget therefore bounds both loop iterations and list allocation. Native conversion is attempted only for 64 through 4095 direct nodes; this independent internal ceiling prevents a caller-raised policy from turning a failed speculative conversion into unbounded temporary allocation.
  4. Batched nodes are deducted before the loop returns or re-enters the general decoder. Nested descendants still consume their own nodes, so a declaration that fits but whose complete tree exceeds max_items still fails.
  5. Negative, mixed, multi-byte, nested, string, float, simple, tag, indefinite, reserved, truncated, or malformed input returns to the unchanged general bounded decoder and retains its stable result.
  6. Deterministic maps never use the pair fast path. Their original encoded key bytes continue through strict ordering and duplicate-key validation.

This is not a second unchecked decoder. It is a fixed-cost inner loop behind the same admission checks and fallback. Exact-limit, one-over-limit, insufficient-input, malformed, nested-budget, deterministic-map, and mixed-form regressions exercise these boundaries on OTP; the compact array/map path is also exercised by the AtomVM and physical maximum suites.

At pinned AtomVM commit ff993a80963298b532c1e573f883951ecaac9fef, erlang:binary_to_list/1 is registered in nifs.gperf:51. Its implementation validates the binary, reserves exactly two heap words per byte, and constructs the result in a native reverse loop in nifs.c:1966-1984. The decoder first bounds the candidate by declared children and its separate 4095-node ceiling, then verifies that every returned byte is a complete preferred unsigned item before accepting the list.

Untrusted request admission

Decoder options are trusted application policy. A network client must never be allowed to supply or enlarge max_items, max_bytes, depth, or string limits. Choose a fixed profile per endpoint; use ble_options/0 or a stricter application profile on constrained devices.

max_bytes rejects an oversized binary at the decoder boundary, but that binary has already been allocated by the transport. HTTP, BLE, MQTT, socket, or file-ingress code must enforce the same or smaller byte limit while reading, before buffering the complete request. On targets whose physical memory is smaller than the library default, the ingress and decoder profile must use a target-specific lower ceiling.

For a request protocol that permits exactly one CBOR item, accept only {ok, Value, <<>>} or {done, Value, <<>>}. Treat non-empty Rest as trailing data and reject it; otherwise an attacker can append data that the application did not authenticate or validate. Long-running MCU handlers should use the continuation API with caller-owned scheduling, while retaining exactly the same fixed resource profile.

At the default profile, an array with 4095 scalar children consumes exactly 4096 nodes and is admitted. A 4096-child array requires 4097 nodes and returns {error, {max_items_exceeded, 4096}}. A tag plus a 2047-pair map consumes exactly 4096 nodes; adding one pair exceeds the same global budget. Inputs above 1 MiB return {error, {max_bytes_exceeded, 1048576}} before parsing.

String limits

max_string_bytes is the positive per-string limit. max_total_string_bytes is a separate positive cumulative byte budget across all byte/text strings and all indefinite chunks in the operation. Both checks happen before slicing or retaining a chunk.

An indefinite string's complete length must fit max_string_bytes; each chunk also decrements max_total_string_bytes and consumes one node. Chunks are held in a bounded reverse list and combined once, avoiding repeated binary concatenation and quadratic copying.

Partial decoding additionally applies positive max_string_size (default

  1. to the complete represented string.

Depth and declared lengths

The top-level item starts at depth zero. Entering an array, map, or tag payload increments depth by one, and max_depth is checked before recursive descent. Values exactly at the configured depth are accepted; the next level fails with {error, {max_depth_exceeded, Limit}}.

Before slicing or growing an accumulator, declared lengths are checked against the remaining input, max_bytes, the relevant string limits, and remaining node/work budget. Oversized or truncated declarations therefore fail without allocation proportional to the declared size.

Indefinite items

When allow_indefinite is true, indefinite arrays and maps use the same depth, node, string, and input limits as definite items. Indefinite strings additionally enforce chunk type and cumulative length:

  • byte strings accept only definite byte-string chunks;
  • text strings accept only definite UTF-8 text-string chunks;
  • nested indefinite string chunks are rejected;
  • a missing break is truncated;
  • a break outside an indefinite context is unexpected_break;
  • a map break after a key but before its value is unexpected_break.

Deterministic mode is stricter than the general indefinite-item policy. When deterministic is true, byte strings, text strings, arrays, maps, nested values, and partial-measurement inputs using additional-information value 31 are rejected with {error, non_deterministic_indefinite}. This prohibition overrides allow_indefinite = true regardless of duplicate-option order. Preferred mode alone does not impose this prohibition.

The constrained BLE profile disables indefinite items.

Constrained embedded profile

avm_cbor:ble_options/0 returns this proplist:

[
    {max_depth, 8},
    {max_items, 64},
    {max_bytes, 512},
    {max_string_bytes, 128},
    {max_total_string_bytes, 256},
    {allow_floats, false},
    {allow_simple, true},
    {allow_tags, false},
    {allow_indefinite, false},
    {preferred, true},
    {deterministic, true}
]

The profile is fixed and caller-selected. The library does not call a physical memory API, use a percentage of nominal RAM, detect PSRAM, or silently choose a larger profile.

RFC 8949 preferred and deterministic profile

  • Integer arguments and definite lengths use their shortest encodings.
  • Preferred/deterministic floats use the shortest exact binary16, binary32, or binary64 representation, including binary16 subnormals and signed zero.
  • Infinity uses binary16 when the runtime can supply a non-finite float.
  • The selected deterministic NaN encoding is binary16 F9 7E 00.
  • Wider exactly representable float encodings are rejected in preferred decode mode with {non_preferred_float, half | single}.
  • Deterministic map keys are ordered lexicographically by their complete encoded key bytes, including cases whose encoded lengths differ.
  • Deterministic full and partial map decoding compares the original encoded key sub-binaries, not decoded Erlang terms. Keys must be strictly increasing; reversed keys return non_deterministic_map_order and identical encoded keys return duplicate_map_key. Each key is recursively validated before the ordering comparison.
  • Deterministic decoding rejects all indefinite-length items. Preferred mode remains distinct and may accept indefinite items when allow_indefinite is true.

The supported BEAM/AtomVM decoder path does not expose NaN or infinity as ordinary Erlang float values; those inputs return {error, {unsupported_simple_value, invalid_float}}. Wider encodings can be rejected for preferred width before that runtime representation check.

Stable public decode errors

Every public decode entry point validates its untrusted boundary. It does not wrap the complete decoder in a broad catch. Important stable results include:

ConditionResult
Non-binary input{error, invalid_input}
Non-list options{error, invalid_options_list}
Unknown, mistyped, zero, or negative option{error, {invalid_option, Option}}
Empty single/partial input{error, empty}
Missing bytes or break{error, truncated}
Reserved additional information{error, reserved_additional_info}
Break in a definite context{error, unexpected_break}
Invalid indefinite chunk type{error, {invalid_indefinite_chunk, Expected}}
Indefinite item in deterministic mode{error, non_deterministic_indefinite}
Deterministic map key is out of encoded-byte order{error, non_deterministic_map_order}
Duplicate encoded deterministic map key{error, duplicate_map_key}
Invalid text{error, invalid_utf8}
Byte, node, depth, or string limit{error, {LimitName, ConfiguredLimit}}
Disallowed float/tag/indefinite itemthe corresponding stable policy error
Wider preferred integer/simple/floatthe corresponding non_preferred_* error
Non-canonical deterministic NaN{error, {non_deterministic_nan, 16#7E00}}
Forged partial descriptor{error, not_a_partial}
Deep partial value with trailing bytes{error, {trailing_bytes, Count}}

Security compatibility corrections in this remediation are intentional:

  • max_items now applies globally instead of resetting per container;
  • zero limits are rejected rather than meaning unlimited;
  • max_total_string_bytes adds a distinct cumulative bound;
  • public non-binary inputs return invalid_input instead of raising;
  • preferred float decoding now rejects wider exact equivalents.

Coverage evidence

OTP cover measures executable lines. It does not provide true source branch coverage, so scripts/branch-coverage.escript compiles a test-only transformed copy of the three source modules and counts these actual outcomes:

  • case, if, multi-clause function, and anonymous-function selection;
  • complete guard true/false results;
  • andalso and orelse short-circuit outcomes;
  • try success and exception handlers.

The production modules are not instrumented. scripts/coverage-gate.sh requires at least 95% total line coverage, total true branch coverage, changed-line coverage, and changed-branch coverage. The defined parsing, budget, public-boundary, and RFC functions require 100% line and branch coverage. Machine-readable evidence is written to coverage/lines.json and coverage/branches.json; coverage/uncovered.txt lists every uncovered line and branch.

Changed-source coverage is enforced only when the diff contains at least one executable line or branch. A 0/0 changed set is reported as not applicable and passes only that delta criterion; the 95% total gates and 100% critical-path gates remain mandatory. This prevents documentation or release-metadata changes from being mislabeled as 0% covered without weakening runtime coverage.

The deterministic indefinite-policy gate, original-key capture, AtomVM-safe bytewise comparator, full map loop, and partial measuring map loop are all in the 100% critical line/true-branch set.

The reproducible randomized suite uses seed {2026,7,30}. Any randomized failure reports that seed and a counterexample minimized by the suite's binary or Erlang-term shrinker.