security: expand taint propagation wrappers
Jaime Fournier <jaimef@linbsd.org>
c51d2a6c241f8ccd307c8646803ebbfa4952d674
diff --git a/docs/kimi3-security-recommmendations.md b/docs/kimi3-security-recommmendations.md
index edbd219..d5a513f 100644
--- a/docs/kimi3-security-recommmendations.md
+++ b/docs/kimi3-security-recommmendations.md
@@ -637,17 +637,18 @@ Taint is opt-in; native sinks don't check it. In the safe prelude, the
`open-input-file`, `open-output-file`, `call-with-input-file`,
`call-with-output-file`, `delete-file`, and `system` to taint-checking safe
wrappers. `tests/test-safe-prelude.ss` locks rejection of tainted values
- through the script-loader default prelude.
+ through the script-loader default prelude. Explicit taint-propagating wrappers
+ now cover trim/split/join, UTF-8 string/bytevector conversion, bytevector
+ copy, and bytevector element reads.
- **Remaining:** (b) Taint sources by default: HTTP request fields, env vars,
file contents read in safe mode, network frames — wrapped at the boundary.
- (c) Propagation coverage: extend the tainted-string operation set to the full
- common string/bytevector API so taint doesn't silently launder away.
- (d) Performance: measure; if overhead matters, document
+ (c) Performance: measure; if overhead matters, document
`*taint-enforce* #f` as an explicit, warned de-opt — never silent.
- **Accept:** partially satisfied: tainted values reaching standard sink names
in safe-prelude code raise `&taint-violation`; untainted flows are covered by
existing safe-prelude tests; `safety-guide.md` has a taint section. Full
- completion still requires source-default and broader propagation work.
+ completion still requires source-default work at protocol/file/env
+ boundaries.
### K3-P1-04 — Decide `define-syntax` in the sandbox allowlist
**Serves:** G1. **Effort:** 2–3 days.
diff --git a/docs/safety-guide.md b/docs/safety-guide.md
index aca462c..4a398ed 100644
--- a/docs/safety-guide.md
+++ b/docs/safety-guide.md
@@ -278,8 +278,10 @@ binds the default file and shell/delete sink names to taint-checking wrappers:
Use `taint-http`, `taint-env`, `taint-file`, `taint-net`, and `taint-deser` at
input boundaries. Use `untaint` only at a reviewed sanitization boundary. Code
-that imports raw Chez or unsafe modules bypasses these wrappers; keep
-`(std security import-audit)` in the build gate for applications.
+that transforms tainted strings or bytevectors should use the `tainted-string-*`
+and `tainted-bytevector-*` wrappers so taint remains attached until the value is
+sanitized. Code that imports raw Chez or unsafe modules bypasses these wrappers;
+keep `(std security import-audit)` in the build gate for applications.
### What the Contracts Check
diff --git a/docs/security-reference.md b/docs/security-reference.md
index 5d7315f..5d54ae8 100644
--- a/docs/security-reference.md
+++ b/docs/security-reference.md
@@ -358,8 +358,11 @@ boundaries without importing a separate module.
`tainted-string-append`, `tainted-string-ref`, `tainted-substring`,
`tainted-string-length`, `tainted-string-upcase`, `tainted-string-downcase`,
-`tainted-string->number`, and `tainted-format` -- operations on tainted strings
-propagate the taint to results where the result carries untrusted data.
+`tainted-string-trim`, `tainted-string-split`, `tainted-string-join`,
+`tainted-string->number`, `tainted-string->utf8`, `tainted-utf8->string`,
+`tainted-bytevector-copy`, `tainted-bytevector-u8-ref`, and
+`tainted-format` -- operations on tainted strings and bytevectors propagate the
+taint to results where the result carries untrusted data.
---
@@ -877,9 +880,11 @@ These are known gaps documented as current limitations, not implementation promi
- **Seccomp architecture coverage is limited.** The BPF bytecode generator supports x86_64 and aarch64 syscall numbers. Use `sandbox-report` in audit logs or release evidence to record which confinement specs were requested, available, or installed for the current platform.
- **Landlock requires Linux 5.13+.** No equivalent on macOS, BSDs, or older Linux kernels. `landlock-available?` returns `#f` on unsupported systems.
- **Taint enforcement depends on the safe surface.** `(jerboa prelude safe)`
- binds default file/shell/delete sink names to taint-checking wrappers, but raw
- Chez operations and unsafe imports do not check taint. Boundary sources still
- need explicit `taint-*` marking until protocol modules wrap inputs by default.
+ binds default file/shell/delete sink names to taint-checking wrappers and
+ exports taint-propagating wrappers for common string/bytevector transforms,
+ but raw Chez operations and unsafe imports do not check taint. Boundary
+ sources still need explicit `taint-*` marking until protocol modules wrap
+ inputs by default.
- **Distributed actor authentication is opt-in at the serialization layer.** `(std actor transport)` authenticates TCP traffic, and `(std actor distributed)` exposes HMAC'd envelopes with timestamp and monotonic sequence replay checks. Compatibility callers that use only `serialize-message` / `deserialize-message` still get parsing limits but no peer authentication.
- **Filesystem capabilities are not process sandboxes.** They validate paths at
the capability API boundary. Code with raw Chez file primitives or
diff --git a/lib/jerboa/prelude/safe.ss b/lib/jerboa/prelude/safe.ss
index de584f4..aa925c9 100644
--- a/lib/jerboa/prelude/safe.ss
+++ b/lib/jerboa/prelude/safe.ss
@@ -119,7 +119,10 @@
check-untainted!
tainted-string-append tainted-string-ref tainted-substring
tainted-string-length tainted-string-upcase tainted-string-downcase ; jerboa-security: suppress string-number-radix-prefix -- export list only; tainted-string->number preserves taint and is not used for protocol lengths
- tainted-string->number tainted-format
+ tainted-string-trim tainted-string-split tainted-string-join
+ tainted-string->number tainted-string->utf8 tainted-utf8->string
+ tainted-bytevector-copy tainted-bytevector-u8-ref tainted-bytevector-length
+ tainted-format
make-taint-violation taint-violation?
taint-violation-class taint-violation-sink
@@ -321,7 +324,15 @@
(def tainted-string-length taint:tainted-string-length)
(def tainted-string-upcase taint:tainted-string-upcase)
(def tainted-string-downcase taint:tainted-string-downcase)
+ (def tainted-string-trim taint:tainted-string-trim)
+ (def tainted-string-split taint:tainted-string-split)
+ (def tainted-string-join taint:tainted-string-join)
(def tainted-string->number taint:tainted-string->number)
+ (def tainted-string->utf8 taint:tainted-string->utf8)
+ (def tainted-utf8->string taint:tainted-utf8->string)
+ (def tainted-bytevector-copy taint:tainted-bytevector-copy)
+ (def tainted-bytevector-u8-ref taint:tainted-bytevector-u8-ref)
+ (def tainted-bytevector-length taint:tainted-bytevector-length)
(def tainted-format taint:tainted-format)
(def make-taint-violation taint:make-taint-violation)
(def taint-violation? taint:taint-violation?)
diff --git a/lib/std/security/taint.ss b/lib/std/security/taint.ss
index c37d92f..0e747a9 100644
--- a/lib/std/security/taint.ss
+++ b/lib/std/security/taint.ss
@@ -34,7 +34,15 @@
tainted-string-length
tainted-string-upcase
tainted-string-downcase
+ tainted-string-trim
+ tainted-string-split
+ tainted-string-join
tainted-string->number
+ tainted-string->utf8
+ tainted-utf8->string
+ tainted-bytevector-copy
+ tainted-bytevector-u8-ref
+ tainted-bytevector-length
tainted-format
;; Safe wrappers (auto-check taint at dangerous sinks)
@@ -51,7 +59,8 @@
taint-violation-sink)
(import (chezscheme)
- (only (jerboa core) def defstruct))
+ (only (jerboa core) def defstruct)
+ (only (std misc string) string-split string-join string-trim))
;; ========== Tainted Value ==========
@@ -175,6 +184,60 @@
[result (apply format #f fmt vals)])
(if cls (taint cls result) result))))
+ (def (first-taint-class values)
+ (let loop ([xs values])
+ (cond
+ [(null? xs) #f]
+ [(tainted? (car xs)) (taint-class (car xs))]
+ [else (loop (cdr xs))])))
+
+ (def (maybe-taint cls value)
+ (if cls (taint cls value) value))
+
+ (def (taint-each cls values)
+ (if cls
+ (map (lambda (v) (taint cls v)) values)
+ values))
+
+ (def (tainted-string-trim s)
+ (tainted-string-transform string-trim s))
+
+ (def tainted-string-split
+ (case-lambda
+ [(s)
+ (let ([cls (first-taint-class (list s))])
+ (taint-each cls (string-split (untaint s))))]
+ [(s sep)
+ (let ([cls (first-taint-class (list s sep))])
+ (taint-each cls (string-split (untaint s) (untaint sep))))]))
+
+ (def (tainted-string-join parts . maybe-sep)
+ (let* ([raw-parts (untaint parts)]
+ [sep (if (null? maybe-sep) #f (car maybe-sep))]
+ [cls (or (and (tainted? parts) (taint-class parts))
+ (first-taint-class raw-parts)
+ (first-taint-class maybe-sep))]
+ [values (map untaint raw-parts)]
+ [result (if (null? maybe-sep)
+ (string-join values)
+ (string-join values (untaint sep)))])
+ (maybe-taint cls result)))
+
+ (def (tainted-string->utf8 s)
+ (maybe-taint (first-taint-class (list s)) (string->utf8 (untaint s))))
+
+ (def (tainted-utf8->string bv)
+ (maybe-taint (first-taint-class (list bv)) (utf8->string (untaint bv))))
+
+ (def (tainted-bytevector-copy bv)
+ (maybe-taint (first-taint-class (list bv)) (bytevector-copy (untaint bv))))
+
+ (def (tainted-bytevector-u8-ref bv i)
+ (maybe-taint (first-taint-class (list bv)) (bytevector-u8-ref (untaint bv) i)))
+
+ (def (tainted-bytevector-length bv)
+ (bytevector-length (untaint bv)))
+
;; ========== Safe Wrappers (auto-enforce taint at dangerous sinks) ==========
;;
;; These wrappers automatically reject tainted arguments at dangerous
diff --git a/tests/test-safe-prelude.ss b/tests/test-safe-prelude.ss
index 54dbdd6..763e7fc 100644
--- a/tests/test-safe-prelude.ss
+++ b/tests/test-safe-prelude.ss
@@ -155,6 +155,15 @@
(equal? (cadr result) "(#t #t #t #t)")))
#t)
+(test "safe prelude exports expanded taint propagation wrappers"
+ (let ([result
+ (run-script-loader
+ "(import (jerboa prelude))\n(define parts (tainted-string-split (taint-http \"a,b\") #\\,))\n(define joined (tainted-string-join parts \"/\"))\n(define bv (tainted-string->utf8 joined))\n(write (list (tainted? joined) (tainted? bv) (tainted? (tainted-bytevector-copy bv)) (taint-value (tainted-bytevector-u8-ref bv 0))))\n"
+ #f)])
+ (and (loader-ok? result)
+ (equal? (cadr result) "(#t #t #t 97)")))
+ #t)
+
(test "script loader unsafe flag preserves raw prelude and warns"
(let ([result
(run-script-loader
diff --git a/tests/test-security-taint.ss b/tests/test-security-taint.ss
index 11be14e..6e0cba3 100644
--- a/tests/test-security-taint.ss
+++ b/tests/test-security-taint.ss
@@ -55,6 +55,18 @@
(test-t "tainted format string keeps taint" (tainted? (tainted-format (taint-http "v=~a") "1")))
(test "tainted format string value" (taint-value (tainted-format (taint-http "v=~a") "1")) "v=1")
(test-f "upcase clean stays clean" (tainted? (tainted-string-upcase "abc")))
+(test-t "string-trim keeps taint" (tainted? (tainted-string-trim (taint-http " x "))))
+(test "string-trim value" (taint-value (tainted-string-trim (taint-http " x "))) "x")
+(test-t "string-split taints pieces" (tainted? (car (tainted-string-split (taint-net "a,b") #\,))))
+(test "string-split piece value" (taint-value (cadr (tainted-string-split (taint-net "a,b") #\,))) "b")
+(test-t "string-join keeps taint" (tainted? (tainted-string-join (list "a" (taint-env "b")) ",")))
+(test "string-join value" (taint-value (tainted-string-join (list "a" (taint-env "b")) ",")) "a,b")
+(test-t "string->utf8 keeps taint" (tainted? (tainted-string->utf8 (taint-http "abc"))))
+(test-t "utf8->string keeps taint" (tainted? (tainted-utf8->string (tainted-string->utf8 (taint-http "abc")))))
+(test-t "bytevector-copy keeps taint" (tainted? (tainted-bytevector-copy (tainted-string->utf8 (taint-http "abc")))))
+(test-t "bytevector-u8-ref keeps taint" (tainted? (tainted-bytevector-u8-ref (tainted-string->utf8 (taint-http "abc")) 0)))
+(test "bytevector-u8-ref value" (taint-value (tainted-bytevector-u8-ref (tainted-string->utf8 (taint-http "abc")) 0)) 97)
+(test "bytevector-length clean" (tainted-bytevector-length (tainted-string->utf8 (taint-http "abc"))) 3)
;; (c) THE regression: a tainted string run through string-upcase then passed
;; to a taint-checking sink is still detected as tainted.