AtlatestRepositorysigil-log
1
;;; Log-forgery tests for (sigil log)'s TEXT formatter.2
;;;3
;;; The text format is the DEFAULT, and before this suite existed a4
;;; string field value was written raw and unquoted after `key=`. Any5
;;; attacker-controlled string could therefore append convincing6
;;; `key=value` pairs — or, with a newline, a whole extra log line — to7
;;; a log entry written about it.8
;;;9
;;; The real attack, and the reason these arms exist: the Slate node's10
;;; anti-DNS-rebinding path answers 421 and logs the request's `Host`11
;;; header. A hostile Host could make a REFUSED request read as ALLOWED12
;;; in the node's own audit line.13
;;;14
;;; EVERY ARM HERE ASSERTS WHAT THE LINE *PARSES AS*, not what it15
;;; contains. A `string-contains?` arm passes this defect forever: the16
;;; forged text is present in both the broken and the fixed rendering,17
;;; only its parse differs.19
(import (sigil test)20
(sigil log)21
(sigil io)22
(sigil string))24
;; ============================================================25
;; A minimal logfmt reader26
;; ============================================================27
;;28
;; Deliberately written the way a log CONSUMER would write one, with no29
;; knowledge of the logger's internals: a key is a run of characters up30
;; to `=`; a value is either a `"`-quoted, backslash-escaped string or a31
;; bare run up to the next space. A bare token containing no `=` (the32
;; prose message) is not a field and is skipped.34
;; Any of these ends a bare token: a log line is one line, and a field35
;; value that is not quoted cannot contain whitespace.36
(define (logfmt-space? c)37
(or (char=? c #\space) (char=? c #\newline)38
(char=? c #\return) (char=? c #\tab)))40
(define (parse-logfmt s)41
(let ((n (string-length s)))42
(let loop ((i 0) (acc '()))43
(cond44
((>= i n) (reverse acc))45
((logfmt-space? (string-ref s i)) (loop (+ i 1) acc))46
(else47
(let key-loop ((j i))48
(cond49
((>= j n) (reverse acc))50
((logfmt-space? (string-ref s j)) (loop (+ j 1) acc))51
((char=? (string-ref s j) #\=)52
(let ((key (substring s i j)))53
(if (and (< (+ j 1) n) (char=? (string-ref s (+ j 1)) #\"))54
(let val-loop ((k (+ j 2)) (out '()))55
(cond56
((>= k n)57
(loop k (cons (cons key (list->string (reverse out))) acc)))58
((char=? (string-ref s k) #\\)59
(if (< (+ k 1) n)60
(let ((e (string-ref s (+ k 1))))61
(if (char=? e #\x)62
;; \xHH; — R7RS hex escape, TERMINATED.63
;; Reading it properly is what lets an arm64
;; tell an escaped control character from65
;; the literal letters "xHH".66
(let hex-loop ((h (+ k 2)) (digits '()))67
(cond68
((>= h n)69
(val-loop h (cons #\? out)))70
((char=? (string-ref s h) #\;)71
(val-loop72
(+ h 1)73
(cons (integer->char74
(string->number75
(list->string (reverse digits))76
16))77
out)))78
(else79
(hex-loop (+ h 1)80
(cons (string-ref s h) digits)))))81
(val-loop (+ k 2)82
(cons (cond ((char=? e #\n) #\newline)83
((char=? e #\r) #\return)84
((char=? e #\t) #\tab)85
(else e))86
out))))87
(val-loop (+ k 1) out)))88
((char=? (string-ref s k) #\")89
(loop (+ k 1) (cons (cons key (list->string (reverse out))) acc)))90
(else (val-loop (+ k 1) (cons (string-ref s k) out)))))91
(let bare-loop ((k (+ j 1)))92
(if (or (>= k n) (logfmt-space? (string-ref s k)))93
(loop k (cons (cons key (substring s (+ j 1) k)) acc))94
(bare-loop (+ k 1)))))))95
(else (key-loop (+ j 1))))))))))97
;; Capture one text-format log line and return it verbatim.98
(define (capture thunk)99
(let ((port (open-output-string)))100
(log-configure! level: 'info format: 'text target: port)101
(thunk)102
(get-output-string port)))104
;; Parse the fields of a captured line.105
(define (fields-of line)106
(let ((i (string-find line "] ")))107
(if i108
(parse-logfmt (substring line (+ i 2) (string-length line)))109
'())))111
(define (field line key)112
(let ((hit (assoc key (fields-of line))))113
(and (pair? hit) (cdr hit))))115
(define (count-char s ch)116
(let ((n (string-length s)))117
(let loop ((i 0) (c 0))118
(if (>= i n)119
c120
(loop (+ i 1) (if (char=? (string-ref s i) ch) (+ c 1) c))))))122
;; ============================================================123
;; The reader itself must be able to fail124
;; ============================================================125
;;126
;; parse-logfmt is the instrument every arm below depends on. If it127
;; silently returned '() for everything, every "no forged field"128
;; assertion would pass for free. These are its positive controls.130
(test-group "logfmt reader (instrument controls)"131
(test "reads bare fields"132
(let ((f (parse-logfmt "a=1 b=two")))133
(assert-equal "1" (cdr (assoc "a" f)))134
(assert-equal "two" (cdr (assoc "b" f)))135
(assert-equal 2 (length f))))137
(test "reads quoted fields and unescapes them"138
(let ((f (parse-logfmt "a=\"x y\" b=\"say \\\"hi\\\"\" c=\"one\\ntwo\"")))139
(assert-equal "x y" (cdr (assoc "a" f)))140
(assert-equal "say \"hi\"" (cdr (assoc "b" f)))141
(assert-equal "one\ntwo" (cdr (assoc "c" f)))))143
(test "skips a prose token that is not a field"144
(let ((f (parse-logfmt "refused host=example port=443")))145
(assert-false (assoc "refused" f))146
(assert-equal 2 (length f))))148
(test "DOES see fields hidden in a raw value — the defect, stated"149
;; This is what the pre-fix formatter produced. The reader must be150
;; able to see the forgery, or it could not detect its absence.151
(let ((f (parse-logfmt "host-header=evil.example status=200")))152
(assert-equal "200" (cdr (assoc "status" f))))))154
;; ============================================================155
;; Field forgery — the real attack156
;; ============================================================158
(test-group "text format: a field value cannot forge fields"159
(test "the Slate 421 Host-header attack produces ONE field"160
;; The shape from packages/slate-cli: a 421 refusal logging the161
;; request's Host header, with a Host chosen to make the refusal162
;; read as an allow.163
(let* ((hostile "evil.example status=200 reason=host-allowed")164
(line (capture (lambda () (log-warn "refused" host-header: hostile)))))165
(assert-false (field line "status"))166
(assert-false (field line "reason"))167
(assert-equal hostile (field line "host-header"))168
(assert-equal 1 (length (fields-of line)))))170
(test "a value crafted to inject allowed=true does not produce an allowed field"171
(let* ((hostile "attacker.example allowed=true")172
(line (capture (lambda () (log-warn "refused" host-header: hostile)))))173
(assert-false (field line "allowed"))174
(assert-equal hostile (field line "host-header"))))176
(test "a value cannot forge a field alongside real ones"177
(let* ((hostile "h status=200")178
(line (capture (lambda ()179
(log-warn "refused" host-header: hostile status: 421)))))180
;; The genuine status: field must survive and must be the 421 one.181
(assert-equal "421" (field line "status"))182
(assert-equal hostile (field line "host-header"))183
(assert-equal 2 (length (fields-of line)))))185
(test "a value containing a newline cannot forge a whole log line"186
(let* ((hostile "h\n2026-01-01T00:00:00Z [INFO] request allowed=true")187
(line (capture (lambda () (log-warn "refused" host-header: hostile)))))188
;; One log event is exactly one line: a single trailing newline.189
(assert-equal 1 (count-char line #\newline))190
(assert-false (field line "allowed"))191
(assert-equal hostile (field line "host-header"))))193
(test "a value containing a carriage return cannot split the line"194
(let* ((hostile "h\r[INFO] allowed=true")195
(line (capture (lambda () (log-warn "refused" host-header: hostile)))))196
(assert-equal 0 (count-char line #\return))197
(assert-false (field line "allowed"))198
(assert-equal hostile (field line "host-header"))))200
(test "a value containing quotes round-trips and forges nothing"201
(let* ((hostile "he said \"a=b\" loudly")202
(line (capture (lambda () (log-warn "refused" host-header: hostile)))))203
(assert-false (field line "b\""))204
(assert-equal hostile (field line "host-header"))205
(assert-equal 1 (length (fields-of line)))))207
(test "a value ending in a backslash does not swallow the closing quote"208
(let* ((hostile "trailing\\")209
(line (capture (lambda ()210
(log-warn "refused" host-header: hostile ok: 1)))))211
(assert-equal hostile (field line "host-header"))212
(assert-equal "1" (field line "ok"))))214
(test "a value containing a tab cannot start a new field"215
(let* ((hostile "h\tallowed=true")216
(line (capture (lambda () (log-warn "refused" host-header: hostile)))))217
(assert-false (field line "allowed"))218
(assert-equal hostile (field line "host-header"))))220
(test "a compound value carrying a hostile string forges nothing"221
(let ((line (capture (lambda ()222
(log-warn "refused"223
detail: (list 1 "x allowed=true" 2))))))224
(assert-false (field line "allowed"))225
(assert-equal 1 (length (fields-of line)))))227
(test "an empty value is a present, empty field — not a missing one"228
(let ((line (capture (lambda () (log-warn "refused" host-header: "" ok: 1)))))229
(assert-equal "" (field line "host-header"))230
(assert-equal "1" (field line "ok"))231
(assert-equal 2 (length (fields-of line))))))233
;; ============================================================234
;; The message is prose, and its ONE guarantee235
;; ============================================================237
(test-group "text format: message stays on one line"238
(test "a newline in the message does not become a second line"239
(let ((line (capture (lambda ()240
(log-warn "a\n2026-01-01T00:00:00Z [INFO] b" ok: 1)))))241
(assert-equal 1 (count-char line #\newline))242
(assert-equal "1" (field line "ok"))))244
(test "ordinary message prose is untouched"245
(let ((line (capture (lambda () (log-warn "Slow query on /api/v1" ms: 12)))))246
(assert-true (string-contains? line "Slow query on /api/v1"))247
(assert-equal "12" (field line "ms")))))249
;; ============================================================250
;; Well-formed values must NOT have started being quoted251
;; ============================================================252
;;253
;; Quoting is conditional. If these arms go red, every existing log line254
;; in the estate changed shape and every human reading one has to relearn255
;; the format — which is the cost this design exists to avoid.257
(test-group "text format: ordinary values render unquoted, as before"258
(test "integers"259
(let ((line (capture (lambda () (log-info "started" port: 8080)))))260
(assert-true (string-contains? line "port=8080"))261
(assert-false (string-contains? line "port=\"8080\""))))263
(test "a path"264
(let ((line (capture (lambda () (log-info "req" path: "/api/v1/things")))))265
(assert-true (string-contains? line "path=/api/v1/things"))))267
(test "a plain string"268
(let ((line (capture (lambda () (log-info "req" user: "alice")))))269
(assert-true (string-contains? line "user=alice"))))271
(test "symbols, booleans and reals"272
(let ((line (capture (lambda () (log-info "s" v: 'a-symbol flag: #t n: 3.5)))))273
(assert-true (string-contains? line "v=a-symbol"))274
(assert-true (string-contains? line "flag=#t"))275
(assert-true (string-contains? line "n=3.5"))))277
(test "a realistic well-formed Host header is not quoted"278
(let ((line (capture (lambda () (log-info "req" host-header: "slate.local:8443")))))279
(assert-true (string-contains? line "host-header=slate.local:8443"))280
(assert-equal "slate.local:8443" (field line "host-header")))))282
;; ============================================================283
;; JSON output was already safe and must stay that way284
;; ============================================================286
(test-group "json format is unaffected"287
(test "a hostile value stays one JSON string"288
(let* ((hostile "evil.example status=200 reason=host-allowed")289
(port (open-output-string)))290
(log-configure! level: 'info format: 'json target: port)291
(log-warn "refused" host-header: hostile)292
(let ((out (get-output-string port)))293
(assert-true (string-contains? out "\"host-header\""))294
(assert-false (string-contains? out "\"status\"")))295
(log-configure! format: 'text))))298
;; ============================================================299
;; KEYS — found by adversarial review, 2026-08-25300
;; ============================================================301
;;302
;; The first version of this fix quoted values and left keys alone, on303
;; the argument that "a key is always a keyword literal written by the304
;; programmer, never a runtime value". THAT ARGUMENT WAS WRONG AND WAS305
;; NEVER PROBED. `string->keyword` accepts any string and is used with306
;; runtime data across the ecosystem, and `(apply log-warn msg kwargs)`307
;; is the natural way to log a dict of attributes. A crafted key forged308
;; both a field and an entire second log line against the "fixed" code.310
(test-group "text format: a KEY cannot forge fields or lines"311
(test "a crafted key cannot inject a second field"312
(let* ((line (capture313
(lambda ()314
(apply log-warn "refused"315
(list (string->keyword "host status=200 reason=host-allowed")316
"evil.example")))))317
(f (fields-of line)))318
(assert-false (assoc "status" f))319
(assert-false (assoc "reason" f))320
(assert-equal 1 (length f))))322
(test "a crafted key cannot forge a whole log line"323
(let ((line (capture324
(lambda ()325
(apply log-warn "refused"326
(list (string->keyword327
"h\n2026-01-01T00:00:00Z [INFO] request allowed=true from")328
"evil.example"))))))329
(assert-equal 1 (count-char line #\newline))330
(assert-false (field line "allowed"))))332
(test "a crafted key cannot smuggle a quote"333
(let ((line (capture334
(lambda ()335
(apply log-info "m"336
(list (string->keyword "a\"b") "v"))))))337
(assert-false (string-contains? line "a\"b"))338
(assert-equal "v" (field line "a_b"))))340
(test "an ordinary keyword key is untouched"341
(let ((line (capture (lambda () (log-info "m" host-header: "x")))))342
(assert-true (string-contains? line "host-header=x")))))344
;; ============================================================345
;; Separators a NON-ASCII log reader honours346
;; ============================================================347
;;348
;; Python's str.splitlines() splits on U+0085, U+2028 and U+2029, and349
;; str.split() additionally on U+00A0 and U+3000. Left raw, those forge350
;; an event for a Python consumer while looking innocent in a terminal.352
(test-group "text format: unicode separators do not split a line"353
(test "U+2028 in a value is escaped"354
(let* ((hostile (string-append "h" (string (integer->char 8232)) "allowed=true"))355
(line (capture (lambda () (log-warn "refused" host-header: hostile)))))356
(assert-false (string-contains? line (string (integer->char 8232))))357
(assert-equal hostile (field line "host-header"))))359
(test "U+0085 in a MESSAGE is escaped"360
(let ((line (capture361
(lambda ()362
(log-warn (string-append "a" (string (integer->char 133))363
"2026-01-01T00:00:00Z [INFO] allowed=true")364
ok: 1)))))365
(assert-false (string-contains? line (string (integer->char 133))))366
(assert-equal "1" (field line "ok"))))368
(test "U+2029 in a MESSAGE is escaped"369
(let ((line (capture370
(lambda ()371
(log-warn (string-append "a" (string (integer->char 8233)) "b") ok: 1)))))372
(assert-false (string-contains? line (string (integer->char 8233))))))374
(test "U+00A0 in a value forces quoting"375
(let* ((v (string-append "a" (string (integer->char 160)) "b"))376
(line (capture (lambda () (log-info "m" k: v)))))377
(assert-equal v (field line "k"))378
(assert-true (string-contains? line "k=\""))))380
(test "ordinary non-ASCII text is NOT byte-escaped"381
;; `write` renders "café au lait" as "caf\xC3\xA9 au lait". A log a382
;; human has to read must not do that.383
(let ((line (capture (lambda () (log-info "m" city: "café au lait")))))384
(assert-true (string-contains? line "café au lait"))385
(assert-equal "café au lait" (field line "city")))))387
;; ============================================================388
;; MESSAGE escaping — the generic control-char rule389
;; ============================================================390
;;391
;; Deleting the generic `(or (< k 32) (= k 127))` branch left every arm392
;; green, because only newline, CR and tab were ever exercised.394
(test-group "text format: message control characters"395
(test "an ESC in a message does not reach the terminal raw"396
(let ((line (capture (lambda () (log-info (string (integer->char 27)) ok: 1)))))397
(assert-false (string-contains? line (string (integer->char 27))))398
(assert-equal "1" (field line "ok"))))400
(test "a NUL in a message is escaped"401
(let ((line (capture (lambda () (log-info (string (integer->char 0)) ok: 1)))))402
(assert-false (string-contains? line (string (integer->char 0))))))404
(test "a DEL in a message is escaped"405
(let ((line (capture (lambda () (log-info (string (integer->char 127)) ok: 1)))))406
(assert-false (string-contains? line (string (integer->char 127))))))408
(test "a non-string message still produces a log line"409
;; It used to produce NOTHING: string-append raised, the fallback410
;; raised the same way, and the entire event vanished silently.411
(let ((line (capture (lambda () (log-info 'a-symbol ok: 1)))))412
(assert-true (string-contains? line "a-symbol"))413
(assert-equal "1" (field line "ok")))))415
;; ============================================================416
;; Rendering assertions that the parse-level arms cannot make417
;; ============================================================418
;;419
;; The empty-value arm above asserts a PARSE, and `host-header= ok=1`420
;; parses identically to `host-header="" ok=1` — so it could not go red421
;; when the empty-value rule was deleted. These assert the bytes.423
(test-group "text format: exact rendering"424
(test "an empty value renders as two quotes, not as nothing"425
(let ((line (capture (lambda () (log-info "m" k: "" ok: 1)))))426
(assert-true (string-contains? line "k=\"\" ok=1"))))428
(test "a value needing quotes renders with them"429
(let ((line (capture (lambda () (log-info "m" k: "a b")))))430
(assert-true (string-contains? line "k=\"a b\""))))432
(test "a value not needing quotes renders without them"433
(let ((line (capture (lambda () (log-info "m" k: "ab")))))434
(assert-true (string-contains? line "k=ab"))435
(assert-false (string-contains? line "k=\"ab\""))))437
(test "a control character is escaped in the terminated R7RS form"438
(let ((line (capture (lambda () (log-info "m" k: (string (integer->char 7)))))))439
(assert-true (string-contains? line "\\x7;")))))441
(run-tests)