Decoder policy and security limits
View SourceThis 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:
- The public entry point checks the complete input against
max_bytesbefore parsing. - The container node is charged normally. Its declared number of direct
children must fit both the remaining
max_itemsbudget 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. - 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. - 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_itemsstill fails. - 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.
- 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
- 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_orderand identical encoded keys returnduplicate_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_indefiniteis 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:
| Condition | Result |
|---|---|
| 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 item | the corresponding stable policy error |
| Wider preferred integer/simple/float | the 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_itemsnow applies globally instead of resetting per container;- zero limits are rejected rather than meaning unlimited;
max_total_string_bytesadds a distinct cumulative bound;- public non-binary inputs return
invalid_inputinstead 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;
andalsoandorelseshort-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.