Address decoder security review: bound collections in max-total
Security review follow-ups (feat/wire-codec):
- F1 (security): max-total now charges collection slot allocation, not only string/bytevector bodies. decode-count charges k x SLOT-COST (dict pairs 2x) toward the running total, so a collection-heavy frame of tiny elements is bounded by max-total instead of only by frame-size x max-count. Regression tests for list/vector/array/dict. - F2 (doc): note that decoding untrusted keyword/symbol names interns them permanently (intern table not GC'd) -> harden before untrusted remote (Enclave) use by gating symbol/keyword decode. Documented in code, README and docs/wire.md; not gated (would break local round-trips). - F3 (doc): note max-depth also bounds host recursion depth; keep the default modest to avoid stack overflow on a hostile deep frame. - F4 (test): malformed-UTF-8 string/keyword/symbol bodies decode with defined behavior (lenient replacement, never a crash/OOB); tests added.
Tests: 121/121 green (native).
README.md | 21 +++++++++++++++++----
docs/wire.md | 16 +++++++++++++++-
src/sigil/wire.sgl | 41 +++++++++++++++++++++++++++++++++--------
test/test-wire.sgl | 37 +++++++++++++++++++++++++++++++++++++
4 files changed, 102 insertions(+), 13 deletions(-)README.mdmodified
|-------|---------|---------|| `max-bytes-len:` | 64 MiB | Largest single string / bytevector body. || `max-count:` | 16 M | Largest collection (list/vector/array length, dict pairs). || `max-depth:` | 256 | Deepest nesting of collections. || `max-total:` | 256 MiB | Total body bytes allocated across the whole message. || `max-depth:` | 256 | Deepest nesting of collections. Decode is recursive, so this **also bounds host stack depth** — keep it modest; cranking it to tens of thousands reintroduces stack-overflow risk on a hostile deeply-nested frame. || `max-total:` | 256 MiB | Total decoded heap: string/bytevector body bytes **plus** an estimated per-element cost for every collection slot, so a collection-heavy frame of tiny elements is bounded too. || `max-int-bytes:` | 1024 | Largest integer payload in varint bytes (bounds bignum size). |## Supported value types never reads out of bounds; an endless varint is rejected rather than spun on.The test suite includes a hostile-input fuzz battery (truncated frames, lyinglength prefixes, cap violations, unknown tags, unknown version, varint abuse)asserting each errors cleanly.length prefixes, cap violations, unknown tags, unknown version, varint abuse,invalid UTF-8 bodies) asserting each errors cleanly (or, for malformed UTF-8,decodes leniently to a string — never a crash or out-of-bounds read).### Harden before untrusted remote (Enclave) useOne residual risk is **not** bounded by the per-frame caps: decoding a keyword orsymbol *interns* its name permanently (the intern table is not garbagecollected). A hostile remote peer that streams an endless supply of uniquekeyword/symbol names can exhaust memory over time. This is safe for local,in-process bulk payloads (the current use), but before the codec carriesuntrusted *remote* traffic over Enclave, symbol/keyword decoding should be gated— decoded as plain strings unless a schema explicitly opts in. That is atransport-level (Part 2) decision and is intentionally not enforced here, sincegating it would break local round-trips.## Builddocs/wire.mdmodified
remaining buffer **before** allocating or copying.3. Enforce configurable limits: maximum single-body length, maximum collection count, maximum nesting depth, maximum total decoded size, maximum integer magnitude — rejecting **before** allocation. magnitude — rejecting **before** allocation. The total-size limit should charge collection allocation (per-element slot cost), not only string/byte bodies, or a collection-heavy frame escapes the bound.4. Use a bounds-checked cursor so a truncated or lying frame errors cleanly and never reads out of bounds.5. Treat an unknown tag, unknown version, or trailing bytes as hard errors.6. If decode is recursive, the nesting-depth limit also bounds host stack depth; keep its default modest so a deeply-nested frame cannot overflow the stack.7. Decode a malformed UTF-8 string/keyword/symbol body with defined behavior (lenient replacement, or a clean error) — never a crash or OOB read.## Hardening for untrusted remote transportInterning a decoded keyword/symbol name is permanent in some hosts (the interntable is not collected). A hostile peer streaming endless unique names canexhaust memory — a risk the per-frame caps do not bound. For untrusted *remote*traffic, gate symbol/keyword decoding (decode as strings unless a schema opts in)at the transport layer. For local in-process payloads this does not apply.## Versioningsrc/sigil/wire.sglmodified
;; against the remaining buffer anyway). (define LEN-VARINT-MAX-BYTES 10) ;; Estimated bytes a single decoded collection slot costs in the host heap ;; (a cons cell / vector slot + overhead). Charged per element toward ;; `max-total` so a collection-heavy frame of tiny elements is bounded by ;; max-total, not merely by frame-size x max-count. A conservative upper ;; bound; it need not be exact, only proportional. (define SLOT-COST 16) ;; ========== Security caps ========== ;;; Build a caps record controlling decode limits. Any omitted field takes ;;; ;;; - `max-bytes-len` — largest single string/bytevector body (bytes) ;;; - `max-count` — largest collection (list/vector length, dict pairs) ;;; - `max-depth` — deepest nesting of collections ;;; - `max-total` — total body bytes allocated across the whole message ;;; - `max-depth` — deepest nesting of collections. NOTE: decode is ;;; recursive, so this ALSO bounds host stack depth — ;;; keep it modest (the default 256 is safe). Cranking ;;; it to tens of thousands reintroduces stack-overflow ;;; risk on a hostile deeply-nested frame. ;;; - `max-total` — total decoded heap charged: string/bytevector body ;;; bytes PLUS an estimated per-element cost for every ;;; collection slot. Bounds total decoded memory. ;;; - `max-int-bytes` — largest int payload (zigzag varint bytes), bounds ;;; bignum size (define (make-wire-caps (keys: (max-bytes-len 67108864) ; 64 MiB ((= tag TAG-INT) (unzigzag (read-uvarint c (dict-ref caps 'max-int-bytes:)))) ((= tag TAG-FLOAT) (decode-float c)) ;; utf8->string is lenient on malformed UTF-8 (produces replacement ;; chars, never crashes / reads OOB), so a hostile string body is safe. ((= tag TAG-STRING) (utf8->string (decode-body c caps))) ((= tag TAG-BYTEVECTOR) (decode-body c caps)) ;; HARDEN-BEFORE-ENCLAVE: string->keyword/string->symbol INTERN the name ;; permanently (the intern table is not GC'd). A hostile remote peer ;; streaming endless unique names exhausts memory over time — the ;; per-frame caps do not bound it. Mitigation (decode-as-string for ;; untrusted transport unless a schema opts in) is a Part-2 transport ;; decision; not gated here because it would break local round-trips. ((= tag TAG-KEYWORD) (string->keyword (utf8->string (decode-body c caps)))) ((= tag TAG-SYMBOL) (string->symbol (utf8->string (decode-body c caps)))) ((= tag TAG-CHAR) (decode-char c)) ;; integer->char validates the range (rejects > #x10FFFF / surrogates). (integer->char cp))) ;; Read a collection count, enforcing depth and count caps. (define (decode-count c caps depth what) ;; Read a collection count, enforcing depth and count caps, and charge the ;; container's own slot allocation (k * slot-bytes) toward max-total. depth ;; also bounds host recursion depth (decode is recursive) — see max-depth. (define (decode-count c caps depth what slot-bytes) (when (>= depth (dict-ref caps 'max-depth:)) (error "wire-decode: nesting depth exceeds cap")) (let ((k (read-length c what 1))) (when (> k (dict-ref caps 'max-count:)) (error "wire-decode: collection count exceeds cap")) (cur-add-total! c (* k slot-bytes) caps) k)) (define (decode-list c caps depth) (let ((k (decode-count c caps depth "list count"))) (let ((k (decode-count c caps depth "list count" SLOT-COST))) (let loop ((i 0) (acc '())) (if (= i k) (reverse acc) (loop (+ i 1) (cons (decode-value c caps (+ depth 1)) acc)))))) (define (decode-vector c caps depth) (let* ((k (decode-count c caps depth "vector count")) (let* ((k (decode-count c caps depth "vector count" SLOT-COST)) (v (make-vector k 0))) (let loop ((i 0)) (if (= i k) (loop (+ i 1))))))) (define (decode-array c caps depth) (let ((k (decode-count c caps depth "array count"))) (let ((k (decode-count c caps depth "array count" SLOT-COST))) (let loop ((i 0) (acc '())) (if (= i k) (list->array (reverse acc)) (loop (+ i 1) (cons (decode-value c caps (+ depth 1)) acc)))))) (define (decode-dict c caps depth) (let ((k (decode-count c caps depth "dict count"))) ;; A dict pair holds two references (key + value), so charge 2 slots each. (let ((k (decode-count c caps depth "dict count" (* 2 SLOT-COST)))) ;; Each pair is two values, so it needs at least 2 bytes; tighten the ;; buffer check accordingly. (when (> (* k 2) (cur-remaining c))test/test-wire.sglmodified
(wire-decode (bytes->bv (append wire-header (list #x04) (make-continuation-bytes 12)))))))))(test-group "decode - malformed string bodies" ;; F4: a string body carrying invalid UTF-8 must have defined behavior — a ;; string (lenient decode) or a clean error — never a crash or OOB read. (test "invalid UTF-8 (bad lead bytes)" (let ((r (guard (e (#t 'clean-error)) (wire-decode (frame #x04 3 #xFF #xFE #x28))))) (assert-true (or (string? r) (eq? r 'clean-error))))) (test "invalid UTF-8 (truncated multibyte)" (let ((r (guard (e (#t 'clean-error)) (wire-decode (frame #x04 2 #xE2 #x82))))) (assert-true (or (string? r) (eq? r 'clean-error))))) (test "invalid UTF-8 in a keyword body" (let ((r (guard (e (#t 'clean-error)) (wire-decode (frame #x06 2 #xC3 #x28))))) (assert-true (or (keyword? r) (eq? r 'clean-error))))))(test-group "decode - trailing garbage" (test "extra byte after a complete value" (assert-true (raises? (lambda () (wire-decode (frame #x01 #x99)))))) (wire-decode bytes (make-wire-caps max-total: 8))))))) (test "generous total cap decodes" (let ((v (list "aaaa" "bbbb"))) (assert-equal v (wire-decode (wire-encode v) (make-wire-caps max-total: 1000))))) ;; F1 regression: a collection of TINY elements (no bodies) must still be ;; bounded by max-total — the container's own slot allocation is charged. (test "list of tiny elements charges slots toward max-total" (let ((bytes (wire-encode (list 1 2 3 4 5 6 7 8 9 10)))) (assert-true (raises? (lambda () (wire-decode bytes (make-wire-caps max-total: 8))))))) (test "vector of tiny elements charges slots toward max-total" (let ((bytes (wire-encode (vector 1 2 3 4 5 6 7 8 9 10)))) (assert-true (raises? (lambda () (wire-decode bytes (make-wire-caps max-total: 8))))))) (test "array of tiny elements charges slots toward max-total" (let ((bytes (wire-encode #[1 2 3 4 5 6 7 8 9 10]))) (assert-true (raises? (lambda () (wire-decode bytes (make-wire-caps max-total: 8))))))) (test "dict of tiny pairs charges slots toward max-total" (let ((bytes (wire-encode #{ a: 1 b: 2 c: 3 d: 4 }))) (assert-true (raises? (lambda () (wire-decode bytes (make-wire-caps max-total: 8))))))) (test "tiny collection within a generous total cap still decodes" (let ((v (list 1 2 3))) (assert-equal v (wire-decode (wire-encode v) (make-wire-caps max-total: 1000))))))(test-group "caps - int magnitude"