security: hide repl protocol errors
Jaime Fournier <jaimef@linbsd.org>
f1c0e089cd7658ac327e0d3ea7c95d67cf7e6a04
diff --git a/docs/kimi3-security-recommmendations.md b/docs/kimi3-security-recommmendations.md
index 1056529..65a1d55 100644
--- a/docs/kimi3-security-recommmendations.md
+++ b/docs/kimi3-security-recommmendations.md
@@ -784,9 +784,11 @@ they are build-time. Close the runtime loop.
embedded boot/bundle build-root byte sequences before C embedding, and gate
both `dist/jerboa` and packaged `.tar.gz` artifacts with
`support/check-release-path-leaks.sh`; the release evidence bundle records
- `path-leaks.txt` and requires `path_leak_status=pass`. Remaining work: router
- integrations plus MCP/LSP/repl-protocol surfaces need opaque client error
- refs.
+ `path-leaks.txt` and requires `path_leak_status=pass`. `(std repl server)`
+ now returns generic client errors with opaque references for eval,
+ eval-region, malformed request, and request-dispatch failures while logging
+ internal detail server-side. Remaining work: router integrations plus MCP/LSP
+ surfaces need opaque client error refs.
### K3-P1-11 — Parameterized-only SQL in the safe surface
**Serves:** G1. **Effort:** 2–3 days.
diff --git a/docs/repl-protocol.md b/docs/repl-protocol.md
index 636d345..96a6cfd 100644
--- a/docs/repl-protocol.md
+++ b/docs/repl-protocol.md
@@ -56,9 +56,12 @@ Success:
Error:
```
-(id :error "error message")
+(id :error "Internal server error (Reference: opaque-id)")
```
+Client-visible protocol errors are intentionally generic. The server logs the
+full condition with the same opaque reference for correlation.
+
Server push (unsolicited):
```
(:push type payload)
@@ -78,7 +81,7 @@ Request: (2 eval "(begin (display 42) 99)")
Response: (2 :ok (:value "99" :stdout "42"))
Request: (3 eval "(/ 1 0)")
-Response: (3 :error "undefined for ~s")
+Response: (3 :error "Internal server error (Reference: opaque-id)")
```
### eval-region
diff --git a/docs/security-reference.md b/docs/security-reference.md
index 4c73c50..0e9bc85 100644
--- a/docs/security-reference.md
+++ b/docs/security-reference.md
@@ -821,7 +821,7 @@ These modules are implemented but not covered in depth above.
| Module | Purpose |
|--------|---------|
| `(std security sanitize)` | Context-aware sanitization: `sanitize-html`, `sanitize-html-attribute`, `sanitize-url-attribute`, `sql-escape`, `sanitize-path`, `safe-path-join`, `sanitize-header-value`, `sanitize-url`. Raises `&path-traversal`, `&header-injection`, `&url-scheme-violation`. |
-| `(std security errors)` | Error classification (internal vs client-safe). Generates opaque error references for correlation. Prevents leaking internal details in error responses; `(std net grpc)` and `(std net thread-httpd)` use it for handler failures. |
+| `(std security errors)` | Error classification (internal vs client-safe). Generates opaque error references for correlation. Prevents leaking internal details in error responses; `(std net grpc)`, `(std net thread-httpd)`, and `(std repl server)` use it for protocol/handler failures. |
| `(std security audit)` | Append-only JSONL audit log with SHA-256 hash chain. `audit-log!`, `verify-audit-chain`, `check-capability!/audit`. |
| `(std security audit-log)` | Per-run structured audit records with JSONL/summary rendering, per-log redactors, and `audit-log-use-env-policy!` to scrub `(std security env)` registered secret values before emission. |
| `(std security auth)` | API key stores, session tokens with expiry, auth middleware pattern, rate limiting for auth attempts. |
diff --git a/docs/unsafe-deserialize-allowlist.sexp b/docs/unsafe-deserialize-allowlist.sexp
index 105f8bf..e1f3ab1 100644
--- a/docs/unsafe-deserialize-allowlist.sexp
+++ b/docs/unsafe-deserialize-allowlist.sexp
@@ -32,7 +32,7 @@
("lib/std/repl.ss" 1010 "bare-read-untrusted" " [n (let ([v (read p)]) (if (eof-object? v) 10 v))]" "explicit REPL expand/trace command over user-entered expressions")
("lib/std/repl.ss" 1030 "bare-read-untrusted" " [expr (read p)]" "explicit REPL expand/trace command over user-entered expressions")
("lib/std/repl.ss" 1031 "bare-read-untrusted" " [n (let ([v (read p)]) (if (eof-object? v) 10 v))]" "explicit REPL expand/trace command over user-entered expressions")
- ("lib/std/repl/server.ss" 346 "runtime-load-trusted" " (load (car args) (lambda (x) (eval x *server-env*)))" "explicit REPL server load command for authenticated/user-selected local code")
+ ("lib/std/repl/server.ss" 356 "runtime-load-trusted" " (load (car args) (lambda (x) (eval x *server-env*)))" "explicit REPL server load command for authenticated/user-selected local code")
("lib/std/secure/compiler.ss" 626 "bare-read-untrusted" " (let ([form (read port)])" "Slang compiler consumes local source text after secure subset checks")
("lib/std/secure/preamble.ss" 241 "bare-read-untrusted" " (filesystem (read path) (write path))" "Slang capability-plan DSL data, not a runtime reader call")
("lib/std/secure/wasm-target.ss" 1390 "bare-read-untrusted" " (let ([form (read p)])" "Slang/WASM target compiler consumes local source text")
diff --git a/lib/std/repl/server.ss b/lib/std/repl/server.ss
index 47a603c..47cb7f8 100644
--- a/lib/std/repl/server.ss
+++ b/lib/std/repl/server.ss
@@ -57,6 +57,7 @@
(import (chezscheme)
(jerboa reader)
(std repl)
+ (std security errors)
(only (jerboa core) def defstruct try catch finally))
;; ========== Server Record ==========
@@ -218,6 +219,19 @@
;; ========== Evaluation Environment ==========
(def *server-env* (interaction-environment))
+ (def repl-safe-error-handler
+ (make-safe-error-handler
+ (lambda (reference class-name exn)
+ (fprintf (current-error-port)
+ "repl-protocol error ref=~a class=~a detail=~s\n"
+ reference
+ class-name
+ exn))))
+ (def (safe-repl-error-message class-name exn)
+ (let ([safe (repl-safe-error-handler class-name exn)])
+ (format "~a (Reference: ~a)"
+ (safe-error-response-message safe)
+ (safe-error-response-reference safe))))
(def (capture-eval expr-str)
;; Evaluate expression string, capturing stdout and stderr
@@ -230,9 +244,7 @@
(format "~s" result)
stdout-str))
(catch (exn) (values 'error
- (if (message-condition? exn)
- (condition-message exn)
- (format "~s" exn))
+ (safe-repl-error-message 'internal-error exn)
""))))
(def (capture-eval-region str)
@@ -258,9 +270,7 @@
(values 'ok (format "~s" result) stdout-str))
)
(catch (exn) (values 'error
- (if (message-condition? exn)
- (condition-message exn)
- (format "~s" exn))
+ (safe-repl-error-message 'internal-error exn)
""))))
;; ========== Request Handling ==========
@@ -431,10 +441,12 @@
:protocol "1.0"))]
[else
- `(,id :error ,(format "unknown method: ~a" method))])
- (catch (exn) `(,id :error ,(if (message-condition? exn)
- (condition-message exn)
- (format "~s" exn)))))))
+ `(,id :error ,(safe-repl-error-message
+ 'bad-request
+ (format "unknown method: ~a" method)))])
+ (catch (exn) `(,id :error ,(safe-repl-error-message
+ 'internal-error
+ exn))))))
;; ========== String helpers ==========
(def (string-contains* haystack needle)
@@ -528,7 +540,9 @@
(set-repl-server-running! server #f)
(loop)))]
[else
- (write `(:push :error "malformed request") outp)
+ (write `(:push :error ,(safe-repl-error-message
+ 'bad-request
+ "malformed request")) outp)
(newline outp)
(flush-output-port outp)
(loop)]))))
diff --git a/tests/test-repl-server.ss b/tests/test-repl-server.ss
index c8acc37..cb26953 100644
--- a/tests/test-repl-server.ss
+++ b/tests/test-repl-server.ss
@@ -133,6 +133,14 @@
(check-true (string-contains* resp ":error")))
(brief-pause)
+ ;; Test: eval errors are opaque and do not leak condition text
+ (let ([resp (nc-request port "(12 eval \"(error 'boom \\\"repl-secret-token-123\\\")\")")])
+ (check-true (string-contains* resp ":error"))
+ (check-true (string-contains* resp "Internal server error"))
+ (check-true (string-contains* resp "Reference:"))
+ (check-true (not (string-contains* resp "repl-secret-token-123"))))
+ (brief-pause)
+
;; Test: complete
(let ([resp (nc-request port "(4 complete \"string-\")")])
(check-true (string-contains* resp ":ok"))