security: fail closed worker memory limits
Jaime Fournier
34b05a8ac39de5b20e854643995b4c96e5ff7a60
--- a/docs/chez-limits.md +++ b/docs/chez-limits.md @@ -26,6 +26,23 @@ time. `/usr/bin/time -l`, macOS `sample <pid>`, and per-thread "what did I do this second" counters in the program itself. +## Security Boundary Note: Untrusted Eval Memory Limits + +Chez gives Jerboa useful in-process tripwires, but a heap cap on the current +thread is not a security boundary. `run-safe-eval` therefore refuses non-`#f` +`max-memory-size` requests instead of pretending to confine the current runtime. + +Use `(std security worker)` `worker-run-eval` for untrusted expressions. The +worker policy exposes `memory-limit-bytes:` as the memory-control intent. On +the current Scheme-only exec facade, requesting it fails closed by default with +status 126 and refused axis `memory-limit`. A caller can set `'fail-closed?: #f` +to launch for diagnostics, but the result reports +`(memory-limit-installed? . #f)` and must not be treated as memory isolation. + +Chez GC knobs such as collection cadence or collect-trip-style settings can be +useful defense-in-depth for cooperative workloads. They do not replace a +process boundary plus pre-exec `rlimit`/platform sandbox installation. + ## 1. Allocator: `S_get_more_room` is a global serialisation point The Chez allocator gives each thread a thread-local allocation buffer --- a/docs/kimi3-security-recommmendations.md +++ b/docs/kimi3-security-recommmendations.md @@ -696,15 +696,17 @@ arithmetic — itself an FFI footgun (`security-reference.md` §13). Chez cannot heap-cap a thread; `run-safe-eval` rightly refuses `max-memory-size`. The worker (P0-02) is the answer — make it ergonomic. -- **Do:** (a) Ship `worker-run-eval` convenience: one call that execs a - confined worker with rlimit AS + CPU, runs the restricted eval, returns - bounded output. (b) Document the hierarchy: in-process `run-safe-eval` - (semi-trusted) → confined worker eval (untrusted) → container/VM - (hostile). (c) Investigate Chez `collect-trip` / maximum-heap parameters - as an in-process tripwire (defense-in-depth, not a boundary) — document - findings either way in `chez-limits.md`. -- **Accept:** eval-bomb test killed by worker rlimit; docs updated; - `run-safe-eval` docs cross-link the worker API. +- **Status:** complete for the Scheme-facing worker API and fail-closed + memory-limit contract. `(std security worker)` ships `worker-run-eval`, + bounded stdout/stderr, parent deadline/process-group kill, and + `memory-limit-bytes:` policy intent. Until the native pre-exec rlimit backend + exists, requested memory limits refuse before launch by default with refused + axis `memory-limit`; degraded diagnostic launches explicitly report + `(memory-limit-installed? . #f)`. +- **Accept:** partially satisfied. Worker memory-limit requests now fail closed + and are regression-tested, and `run-safe-eval` docs cross-link the worker API + plus `docs/chez-limits.md`. The eval-bomb killed by rlimit AS remains pending + with the native P0-02 pre-exec backend. ### K3-P1-08 — Platform sandbox parity **Serves:** G2. **Effort:** 2 weeks. --- a/docs/security-reference.md +++ b/docs/security-reference.md @@ -379,6 +379,7 @@ child entrypoint `support/security-worker-main.ss`. "(+ 1 2)" (worker-policy 'timeout-ms: 1000 + 'memory-limit-bytes: #f 'stdout-cap-bytes: 65536 'stderr-cap-bytes: 65536))) @@ -394,12 +395,30 @@ are fail-closed: if a policy requires an unavailable sandbox axis, the worker returns `launched? = #f`, status `126`, and the refused axis list instead of silently running with weaker controls. +`worker-policy` accepts `memory-limit-bytes:` as the memory-control intent for +untrusted evaluation. On the current Scheme-only exec facade, a non-`#f` value +is refused by default before launch because the native pre-exec rlimit backend +is not installed yet: + +```scheme +(worker-run-eval + "(let loop () (loop))" + (worker-policy + 'timeout-ms: 1000 + 'memory-limit-bytes: (* 64 1024 1024))) +;; => launched? #f, status 126, refused axes include memory-limit +``` + +Callers may set `'fail-closed?: #f` to launch anyway for diagnostics, but the +result will report `(memory-limit-installed? . #f)`. Do not treat that mode as +memory isolation. + Current limitation: the worker has a real exec boundary, pure environment, parent deadline, process-group kill through `aproc`, returned output caps, and audit-log start/end records in `worker-result-diagnostics`. The remaining -pre-exec kernel-control backend is not complete, so rlimit, Landlock/seccomp, -Seatbelt/Capsicum installation, and deny-default egress proxy wiring remain -tracked by the K3 handoff. +pre-exec kernel-control backend is not complete, so rlimit installation, +Landlock/seccomp, Seatbelt/Capsicum installation, and deny-default egress proxy +wiring remain tracked by the K3 handoff. --- @@ -474,6 +493,12 @@ that fresh process. A supervisor should own deadlines and bound the worker's stdin, stdout, and stderr. Never run Scheme in the child between `fork` and `exec`. +For untrusted expressions, use `(std security worker)` `worker-run-eval`. +`run-safe-eval` cannot heap-cap the current Chez thread; non-`#f` +`max-memory-size` requests raise phase `'fork`. The worker API exposes +`memory-limit-bytes:` for that intent and fails closed until the native +pre-exec rlimit backend can install it. + ### API ```scheme --- a/lib/std/security/worker.ss +++ b/lib/std/security/worker.ss @@ -19,6 +19,7 @@ worker-policy-env-policy worker-policy-cwd worker-policy-timeout-ms + worker-policy-memory-limit-bytes worker-policy-stdout-cap-bytes worker-policy-stderr-cap-bytes worker-policy-require @@ -57,15 +58,23 @@ env-policy-audit-summary)) (defstruct %worker-policy - (command env-policy cwd timeout-ms stdout-cap-bytes stderr-cap-bytes - require fail-closed?)) + (command env-policy cwd timeout-ms memory-limit-bytes + stdout-cap-bytes stderr-cap-bytes require fail-closed?)) - (def make-worker-policy make-%worker-policy) + (def make-worker-policy + (case-lambda + [(command env-policy cwd timeout-ms stdout-cap-bytes stderr-cap-bytes require fail-closed?) + (make-%worker-policy command env-policy cwd timeout-ms #f + stdout-cap-bytes stderr-cap-bytes require fail-closed?)] + [(command env-policy cwd timeout-ms memory-limit-bytes stdout-cap-bytes stderr-cap-bytes require fail-closed?) + (make-%worker-policy command env-policy cwd timeout-ms memory-limit-bytes + stdout-cap-bytes stderr-cap-bytes require fail-closed?)])) (def worker-policy? %worker-policy?) (def worker-policy-command %worker-policy-command) (def worker-policy-env-policy %worker-policy-env-policy) (def worker-policy-cwd %worker-policy-cwd) (def worker-policy-timeout-ms %worker-policy-timeout-ms) + (def worker-policy-memory-limit-bytes %worker-policy-memory-limit-bytes) (def worker-policy-stdout-cap-bytes %worker-policy-stdout-cap-bytes) (def worker-policy-stderr-cap-bytes %worker-policy-stderr-cap-bytes) (def worker-policy-require %worker-policy-require) @@ -108,6 +117,7 @@ [env-pol (env-policy-default)] [cwd #f] [timeout-ms 5000] + [memory-limit-bytes #f] [stdout-cap-bytes (* 1024 1024)] [stderr-cap-bytes (* 256 1024)] [require '()] @@ -116,6 +126,7 @@ (cond [(null? xs) (make-worker-policy command env-pol cwd timeout-ms + memory-limit-bytes stdout-cap-bytes stderr-cap-bytes require fail-closed?)] [(null? (cdr xs)) @@ -127,6 +138,10 @@ [(env-policy:) (set! env-pol val)] [(cwd:) (set! cwd val)] [(timeout-ms:) (set! timeout-ms val)] + [(memory-limit-bytes:) + (unless (or (not val) (and (integer? val) (> val 0))) + (error 'worker-policy "memory-limit-bytes must be #f or a positive integer" val)) + (set! memory-limit-bytes val)] [(stdout-cap-bytes:) (set! stdout-cap-bytes val)] [(stderr-cap-bytes:) (set! stderr-cap-bytes val)] [(require:) (set! require val)] @@ -158,6 +173,18 @@ [(axis-installed? (car xs) caps) (lp (cdr xs) out)] [else (lp (cdr xs) (cons (car xs) out))]))) + (def (memory-limit-refused-axes pol) + (if (worker-policy-memory-limit-bytes pol) + '(memory-limit) + '())) + + (def (append-refused-axes a b) + (let lp ([xs b] [out a]) + (cond + [(null? xs) out] + [(memq (car xs) out) (lp (cdr xs) out)] + [else (lp (cdr xs) (append out (list (car xs))))]))) + (def (truncate-string s cap) (cond [(not cap) s] @@ -177,6 +204,7 @@ missing `((backend . ,(sandbox-backend)) (capabilities . ,caps) + (memory-limit-bytes . ,(worker-policy-memory-limit-bytes pol)) (env . ,(env-policy-audit-summary (worker-policy-env-policy pol))) (reason . ,reason)) command)) @@ -221,6 +249,8 @@ '() `((backend . ,(sandbox-backend)) (capabilities . ,caps) + (memory-limit-bytes . ,(worker-policy-memory-limit-bytes pol)) + (memory-limit-installed? . #f) (stdout-truncated? . ,stdout-truncated?) (stderr-truncated? . ,stderr-truncated?) (env . ,(env-policy-audit-summary (worker-policy-env-policy pol))) @@ -235,7 +265,9 @@ (unless (env-policy? (worker-policy-env-policy pol)) (error 'worker-run-command "expected env-policy" (worker-policy-env-policy pol))) (let* ([caps (sandbox-capabilities)] - [missing (missing-required-axes (worker-policy-require pol) caps)]) + [required-missing (missing-required-axes (worker-policy-require pol) caps)] + [memory-missing (memory-limit-refused-axes pol)] + [missing (append-refused-axes required-missing memory-missing)]) (cond [(and (worker-policy-fail-closed? pol) (pair? missing)) (worker-refusal-result command pol caps missing 'required-axis-unavailable)] @@ -279,6 +311,7 @@ (worker-policy-env-policy pol0) (worker-policy-cwd pol0) (worker-policy-timeout-ms pol0) + (worker-policy-memory-limit-bytes pol0) (worker-policy-stdout-cap-bytes pol0) (worker-policy-stderr-cap-bytes pol0) (worker-policy-require pol0) --- a/tests/test-worker.ss +++ b/tests/test-worker.ss @@ -138,5 +138,42 @@ (equal? (worker-result-refused-axes r) '(definitely-unavailable-worker-axis))))) +(define requested-memory-limit (* 64 1024 1024)) + +(test-pred "memory limit refuses before launch by default" + (worker-run-eval + "(+ 1 1)" + (worker-policy + 'command: worker-command + 'timeout-ms: 3000 + 'memory-limit-bytes: requested-memory-limit)) + (lambda (r) + (and (worker-result? r) + (not (worker-result-launched? r)) + (equal? (worker-result-status r) 126) + (memq 'memory-limit (worker-result-refused-axes r)) + (equal? (alist-ref/default (worker-result-diagnostics r) + 'memory-limit-bytes #f) + requested-memory-limit)))) + +(test-pred "memory limit degraded launch reports unenforced axis" + (worker-run-eval + "(+ 1 1)" + (worker-policy + 'command: worker-command + 'timeout-ms: 3000 + 'memory-limit-bytes: requested-memory-limit + 'fail-closed?: #f)) + (lambda (r) + (and (worker-result? r) + (worker-result-launched? r) + (equal? (worker-result-status r) 0) + (equal? (alist-ref/default (worker-result-diagnostics r) + 'memory-limit-bytes #f) + requested-memory-limit) + (eq? (alist-ref/default (worker-result-diagnostics r) + 'memory-limit-installed? #t) + #f)))) + (printf "worker tests: ~a passed, ~a failed~%" pass fail) (when (> fail 0) (exit 1))