Skip to content

Hostile Input

A tagless decoder relies on the schema to interpret every byte. Bounds and length checks are therefore essential when payloads are untrusted.

CheckBehavior
Read past end of inputDecodeError with byte offset
Trailing bytes after a complete valueDecodeError
Non-canonical (overlong) varintDecodeError
Varint beyond the safe integer rangeDecodeError
Invalid UTF-8DecodeError — fatal, not replacing
Boolean byte other than 0 or 1DecodeError
Enum index past the last memberDecodeError
Unknown object propertyEncodeError on the way in
Input that is not a Uint8ArrayDecodeError, not a raw TypeError
__proto__ as a decoded keyhandled; the decode target is null-prototype

The suite covers every truncated prefix of a valid payload, trailing data, invalid booleans and enum indexes, invalid UTF-8, over-limit array lengths, non-canonical varints, mutable schema declarations, open-object rejection, unknown properties, and __proto__. Property-based round-trip and mutation tests are in test/fuzz.test.ts.

Hard limits are 1,000,000 collection elements and 64 MiB for strings or byte arrays. These are backstops; the input-length checks below provide the main allocation defense.

Allocation is bounded by input length, not schema shape

Section titled “Allocation is bounded by input length, not schema shape”

A naive decoder can allocate far more memory than the payload size suggests. A seven-byte payload can declare an array with one million elements, and nested arrays can multiply that allocation.

Every schema carries a _minWidth, the fewest bytes one value can occupy. Before allocating an array, the decoder multiplies this width by the declared count and checks that enough input remains.

PayloadBeforeAfter
7 bytes, ordinary nested-array schema16.0 MB allocated, 2,287,431:10.01 MB, rejected at byte 3
Same payload, decodes/s809227,596
3 bytes, array(literal)1,000,000 elementsrefused when the schema is built

Because _minWidth is computed during codec construction, the runtime check adds one multiplication per decoded array.

This is why arrays of zero-width elements are rejected during codec construction. Literals, empty tuples, and empty objects use no bytes, so the decoder could not verify the declared count against the payload length. A tuple may still contain them because its length comes from the schema.

encode returns an exact-size copy instead of a view into an oversized buffer. It also releases internal buffers larger than 64 KiB. Encoding a 4.04 MiB payload now retains 4.11 MiB, down from 12.10 MiB.

Listed so you can judge the risk rather than infer safety from silence:

  • No coverage-guided fuzzing. Property-based and mutation tests exist; a coverage-guided fuzzer does not.
  • No depth limit. A schema nested about 5,900 levels deep can exhaust the JavaScript stack and throw RangeError instead of DecodeError. This requires a hostile schema, not merely hostile bytes. Limit depth if schemas come from untrusted input.
  • No peak-allocation or allocation-rate profiling. Retained memory is measured; transient churn is not.
  • No validation-failure throughput or adversarial compression tests.
  • No browser-runtime matrix. Bundling is measured; execution is not.

Use safeDecode at untrusted boundaries, where malformed input should be handled as normal traffic.

const result = safeDecode(Person, bytes);
if (!result.success) return new Response("Bad request", { status: 400 });

Use fingerprinted for stored and queued payloads. It is not a security feature because the digest is unkeyed and forgeable. It prevents schema mismatches from silently producing incorrect data.

Encrypt when secrecy matters. Compact is not confidential.

Cap payload size at the transport. The 64 MiB limit is a backstop, not a policy.

Do not treat the fingerprint as authentication. Sign or encrypt if you need authenticity.