Commit7acefdbcRecorded21 Jul 2026Repositorysigil-wire
Address decoder security review: bound collections in max-total
Message
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).
Changed
README.md | 21 +++++++++++++++++----
docs/wire.md | 16 +++++++++++++++-
src/sigil/wire.sgl | 41 +++++++++++++++++++++++++++++++++--------
test/test-wire.sgl | 37 +++++++++++++++++++++++++++++++++++++
4 files changed, 102 insertions(+), 13 deletions(-)Diff
README.mdmodified
@@ -48,8 +48,8 @@ Enclave). Pass a caps record to bound the work a hostile frame can trigger:
48
|-------|---------|---------| 49
| `max-bytes-len:` | 64 MiB | Largest single string / bytevector body. | 50
| `max-count:` | 16 M | Largest collection (list/vector/array length, dict pairs). |−51
| `max-depth:` | 256 | Deepest nesting of collections. |−52
| `max-total:` | 256 MiB | Total body bytes allocated across the whole message. |+51
| `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. |+52
| `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. | 53
| `max-int-bytes:` | 1024 | Largest integer payload in varint bytes (bounds bignum size). | 54
55
## Supported value types@@ -127,8 +127,21 @@ The decoder never trusts a length it reads:
127
never reads out of bounds; an endless varint is rejected rather than spun on. 128
129
The test suite includes a hostile-input fuzz battery (truncated frames, lying−130
length prefixes, cap violations, unknown tags, unknown version, varint abuse)−131
asserting each errors cleanly.+130
length prefixes, cap violations, unknown tags, unknown version, varint abuse,+131
invalid UTF-8 bodies) asserting each errors cleanly (or, for malformed UTF-8,+132
decodes leniently to a string — never a crash or out-of-bounds read).+133
+134
### Harden before untrusted remote (Enclave) use+135
+136
One residual risk is **not** bounded by the per-frame caps: decoding a keyword or+137
symbol *interns* its name permanently (the intern table is not garbage+138
collected). A hostile remote peer that streams an endless supply of unique+139
keyword/symbol names can exhaust memory over time. This is safe for local,+140
in-process bulk payloads (the current use), but before the codec carries+141
untrusted *remote* traffic over Enclave, symbol/keyword decoding should be gated+142
— decoded as plain strings unless a schema explicitly opts in. That is a+143
transport-level (Part 2) decision and is intentionally not enforced here, since+144
gating it would break local round-trips. 145
146
## Build 147
docs/wire.mdmodified
@@ -155,10 +155,24 @@ An implementation reading untrusted bytes MUST:
155
remaining buffer **before** allocating or copying. 156
3. Enforce configurable limits: maximum single-body length, maximum collection 157
count, maximum nesting depth, maximum total decoded size, maximum integer−158
magnitude — rejecting **before** allocation.+158
magnitude — rejecting **before** allocation. The total-size limit should+159
charge collection allocation (per-element slot cost), not only string/byte+160
bodies, or a collection-heavy frame escapes the bound. 161
4. Use a bounds-checked cursor so a truncated or lying frame errors cleanly and 162
never reads out of bounds. 163
5. Treat an unknown tag, unknown version, or trailing bytes as hard errors.+164
6. If decode is recursive, the nesting-depth limit also bounds host stack depth;+165
keep its default modest so a deeply-nested frame cannot overflow the stack.+166
7. Decode a malformed UTF-8 string/keyword/symbol body with defined behavior+167
(lenient replacement, or a clean error) — never a crash or OOB read.+168
+169
## Hardening for untrusted remote transport+170
+171
Interning a decoded keyword/symbol name is permanent in some hosts (the intern+172
table is not collected). A hostile peer streaming endless unique names can+173
exhaust memory — a risk the per-frame caps do not bound. For untrusted *remote*+174
traffic, gate symbol/keyword decoding (decode as strings unless a schema opts in)+175
at the transport layer. For local in-process payloads this does not apply. 176
177
## Versioning 178
src/sigil/wire.sglmodified
@@ -111,6 +111,13 @@
111
;; against the remaining buffer anyway). 112
(define LEN-VARINT-MAX-BYTES 10) 113
+114
;; Estimated bytes a single decoded collection slot costs in the host heap+115
;; (a cons cell / vector slot + overhead). Charged per element toward+116
;; `max-total` so a collection-heavy frame of tiny elements is bounded by+117
;; max-total, not merely by frame-size x max-count. A conservative upper+118
;; bound; it need not be exact, only proportional.+119
(define SLOT-COST 16)+120
121
;; ========== Security caps ========== 122
123
;;; Build a caps record controlling decode limits. Any omitted field takes@@ -118,8 +125,14 @@
125
;;; 126
;;; - `max-bytes-len` — largest single string/bytevector body (bytes) 127
;;; - `max-count` — largest collection (list/vector length, dict pairs)−121
;;; - `max-depth` — deepest nesting of collections−122
;;; - `max-total` — total body bytes allocated across the whole message+128
;;; - `max-depth` — deepest nesting of collections. NOTE: decode is+129
;;; recursive, so this ALSO bounds host stack depth —+130
;;; keep it modest (the default 256 is safe). Cranking+131
;;; it to tens of thousands reintroduces stack-overflow+132
;;; risk on a hostile deeply-nested frame.+133
;;; - `max-total` — total decoded heap charged: string/bytevector body+134
;;; bytes PLUS an estimated per-element cost for every+135
;;; collection slot. Bounds total decoded memory. 136
;;; - `max-int-bytes` — largest int payload (zigzag varint bytes), bounds 137
;;; bignum size 138
(define (make-wire-caps (keys: (max-bytes-len 67108864) ; 64 MiB@@ -487,8 +500,16 @@
500
((= tag TAG-INT) 501
(unzigzag (read-uvarint c (dict-ref caps 'max-int-bytes:)))) 502
((= tag TAG-FLOAT) (decode-float c))+503
;; utf8->string is lenient on malformed UTF-8 (produces replacement+504
;; chars, never crashes / reads OOB), so a hostile string body is safe. 505
((= tag TAG-STRING) (utf8->string (decode-body c caps))) 506
((= tag TAG-BYTEVECTOR) (decode-body c caps))+507
;; HARDEN-BEFORE-ENCLAVE: string->keyword/string->symbol INTERN the name+508
;; permanently (the intern table is not GC'd). A hostile remote peer+509
;; streaming endless unique names exhausts memory over time — the+510
;; per-frame caps do not bound it. Mitigation (decode-as-string for+511
;; untrusted transport unless a schema opts in) is a Part-2 transport+512
;; decision; not gated here because it would break local round-trips. 513
((= tag TAG-KEYWORD) (string->keyword (utf8->string (decode-body c caps)))) 514
((= tag TAG-SYMBOL) (string->symbol (utf8->string (decode-body c caps)))) 515
((= tag TAG-CHAR) (decode-char c))@@ -521,24 +542,27 @@
542
;; integer->char validates the range (rejects > #x10FFFF / surrogates). 543
(integer->char cp))) 544
−524
;; Read a collection count, enforcing depth and count caps.−525
(define (decode-count c caps depth what)+545
;; Read a collection count, enforcing depth and count caps, and charge the+546
;; container's own slot allocation (k * slot-bytes) toward max-total. depth+547
;; also bounds host recursion depth (decode is recursive) — see max-depth.+548
(define (decode-count c caps depth what slot-bytes) 549
(when (>= depth (dict-ref caps 'max-depth:)) 550
(error "wire-decode: nesting depth exceeds cap")) 551
(let ((k (read-length c what 1))) 552
(when (> k (dict-ref caps 'max-count:)) 553
(error "wire-decode: collection count exceeds cap"))+554
(cur-add-total! c (* k slot-bytes) caps) 555
k)) 556
557
(define (decode-list c caps depth)−534
(let ((k (decode-count c caps depth "list count")))+558
(let ((k (decode-count c caps depth "list count" SLOT-COST))) 559
(let loop ((i 0) (acc '())) 560
(if (= i k) 561
(reverse acc) 562
(loop (+ i 1) (cons (decode-value c caps (+ depth 1)) acc)))))) 563
564
(define (decode-vector c caps depth)−541
(let* ((k (decode-count c caps depth "vector count"))+565
(let* ((k (decode-count c caps depth "vector count" SLOT-COST)) 566
(v (make-vector k 0))) 567
(let loop ((i 0)) 568
(if (= i k)@@ -548,14 +572,15 @@
572
(loop (+ i 1))))))) 573
574
(define (decode-array c caps depth)−551
(let ((k (decode-count c caps depth "array count")))+575
(let ((k (decode-count c caps depth "array count" SLOT-COST))) 576
(let loop ((i 0) (acc '())) 577
(if (= i k) 578
(list->array (reverse acc)) 579
(loop (+ i 1) (cons (decode-value c caps (+ depth 1)) acc)))))) 580
581
(define (decode-dict c caps depth)−558
(let ((k (decode-count c caps depth "dict count")))+582
;; A dict pair holds two references (key + value), so charge 2 slots each.+583
(let ((k (decode-count c caps depth "dict count" (* 2 SLOT-COST)))) 584
;; Each pair is two values, so it needs at least 2 bytes; tighten the 585
;; buffer check accordingly. 586
(when (> (* k 2) (cur-remaining c))test/test-wire.sglmodified
@@ -238,6 +238,22 @@
238
(wire-decode (bytes->bv (append wire-header (list #x04) 239
(make-continuation-bytes 12))))))))) 240
+241
(test-group "decode - malformed string bodies"+242
;; F4: a string body carrying invalid UTF-8 must have defined behavior — a+243
;; string (lenient decode) or a clean error — never a crash or OOB read.+244
(test "invalid UTF-8 (bad lead bytes)"+245
(let ((r (guard (e (#t 'clean-error))+246
(wire-decode (frame #x04 3 #xFF #xFE #x28)))))+247
(assert-true (or (string? r) (eq? r 'clean-error)))))+248
(test "invalid UTF-8 (truncated multibyte)"+249
(let ((r (guard (e (#t 'clean-error))+250
(wire-decode (frame #x04 2 #xE2 #x82)))))+251
(assert-true (or (string? r) (eq? r 'clean-error)))))+252
(test "invalid UTF-8 in a keyword body"+253
(let ((r (guard (e (#t 'clean-error))+254
(wire-decode (frame #x06 2 #xC3 #x28)))))+255
(assert-true (or (keyword? r) (eq? r 'clean-error))))))+256
257
(test-group "decode - trailing garbage" 258
(test "extra byte after a complete value" 259
(assert-true (raises? (lambda () (wire-decode (frame #x01 #x99))))))@@ -287,6 +303,27 @@
303
(wire-decode bytes (make-wire-caps max-total: 8))))))) 304
(test "generous total cap decodes" 305
(let ((v (list "aaaa" "bbbb")))+306
(assert-equal v (wire-decode (wire-encode v) (make-wire-caps max-total: 1000)))))+307
;; F1 regression: a collection of TINY elements (no bodies) must still be+308
;; bounded by max-total — the container's own slot allocation is charged.+309
(test "list of tiny elements charges slots toward max-total"+310
(let ((bytes (wire-encode (list 1 2 3 4 5 6 7 8 9 10))))+311
(assert-true (raises? (lambda ()+312
(wire-decode bytes (make-wire-caps max-total: 8)))))))+313
(test "vector of tiny elements charges slots toward max-total"+314
(let ((bytes (wire-encode (vector 1 2 3 4 5 6 7 8 9 10))))+315
(assert-true (raises? (lambda ()+316
(wire-decode bytes (make-wire-caps max-total: 8)))))))+317
(test "array of tiny elements charges slots toward max-total"+318
(let ((bytes (wire-encode #[1 2 3 4 5 6 7 8 9 10])))+319
(assert-true (raises? (lambda ()+320
(wire-decode bytes (make-wire-caps max-total: 8)))))))+321
(test "dict of tiny pairs charges slots toward max-total"+322
(let ((bytes (wire-encode #{ a: 1 b: 2 c: 3 d: 4 })))+323
(assert-true (raises? (lambda ()+324
(wire-decode bytes (make-wire-caps max-total: 8)))))))+325
(test "tiny collection within a generous total cap still decodes"+326
(let ((v (list 1 2 3))) 327
(assert-equal v (wire-decode (wire-encode v) (make-wire-caps max-total: 1000)))))) 328
329
(test-group "caps - int magnitude"