dream: HTML escaping (dream-escape) + fix XSS hole in todo demo + 11 tests
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 1m9s

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-07 15:18:49 +00:00
parent 85aea61f3c
commit 0366373c8a
5 changed files with 94 additions and 2 deletions

View File

@@ -34,6 +34,7 @@ MODULES=(
"lib/dream/cors.sx"
"lib/dream/json.sx"
"lib/dream/auth.sx"
"lib/dream/html.sx"
"lib/dream/run.sx"
"lib/dream/api.sx"
"lib/dream/demos/hello.sx"
@@ -56,6 +57,7 @@ SUITES=(
"cors dream-co-tests-run! lib/dream/tests/cors.sx"
"json dream-js-tests-run! lib/dream/tests/json.sx"
"auth dream-au-tests-run! lib/dream/tests/auth.sx"
"html dream-ht-tests-run! lib/dream/tests/html.sx"
"run dream-rn-tests-run! lib/dream/tests/run.sx"
"api dream-ap-tests-run! lib/dream/tests/api.sx"
"demos dream-dm-tests-run! lib/dream/tests/demos.sx"

View File

@@ -1,6 +1,7 @@
;; lib/dream/demos/todo.sx — CRUD todo list with forms + CSRF (todo.ml).
;; An in-memory store holds items; add/toggle/delete go through POST forms guarded
;; by the CSRF middleware. Wires session -> csrf -> router.
;; by the CSRF middleware. User text is HTML-escaped on render (dream-escape).
;; Wires session -> csrf -> router.
(define
dream-todo-store
@@ -19,7 +20,7 @@
acc
"<li>"
(if (get it :done) "[x] " "[ ] ")
(get it :text)
(dream-escape (get it :text))
"</li>"))
""
((get store :all)))

24
lib/dream/html.sx Normal file
View File

@@ -0,0 +1,24 @@
;; lib/dream/html.sx — Dream-on-SX HTML escaping for safe templating.
;; Interpolating user input into HTML without escaping is an XSS hole; dream-escape
;; neutralises it. Depends on nothing (pure string ops).
;; escape text for HTML element content / double-quoted attributes
(define
dream-escape
(fn
(s)
(replace
(replace
(replace (replace (replace s "&" "&amp;") "<" "&lt;") ">" "&gt;")
"\""
"&quot;")
"'"
"&#39;")))
;; build a single attribute: name="escaped-value"
(define dream-attr (fn (name val) (str name "=\"" (dream-escape val) "\"")))
;; join escaped text with a separator, escaping each piece
(define
dream-escape-join
(fn (sep pieces) (join sep (map dream-escape pieces))))

59
lib/dream/tests/html.sx Normal file
View File

@@ -0,0 +1,59 @@
;; lib/dream/tests/html.sx — HTML escaping (+ demo XSS regression).
(define dream-ht-pass 0)
(define dream-ht-fail 0)
(define dream-ht-fails (list))
(define
dream-ht-test
(fn
(name actual expected)
(if
(= actual expected)
(set! dream-ht-pass (+ dream-ht-pass 1))
(begin
(set! dream-ht-fail (+ dream-ht-fail 1))
(append! dream-ht-fails {:name name :actual actual :expected expected})))))
(dream-ht-test "escape ampersand" (dream-escape "a & b") "a &amp; b")
(dream-ht-test "escape lt gt" (dream-escape "<b>") "&lt;b&gt;")
(dream-ht-test "escape quote" (dream-escape "say \"hi\"") "say &quot;hi&quot;")
(dream-ht-test "escape apostrophe" (dream-escape "it's") "it&#39;s")
(dream-ht-test
"escape script tag"
(dream-escape "<script>alert(1)</script>")
"&lt;script&gt;alert(1)&lt;/script&gt;")
(dream-ht-test
"ampersand first (no double-escape)"
(dream-escape "&lt;")
"&amp;lt;")
(dream-ht-test
"safe string unchanged"
(dream-escape "hello world")
"hello world")
(dream-ht-test
"attr escapes value"
(dream-attr "title" "a\"b")
"title=\"a&quot;b\"")
(dream-ht-test
"escape-join"
(dream-escape-join " " (list "<a>" "<b>"))
"&lt;a&gt; &lt;b&gt;")
;; ── todo demo escapes user input (XSS regression) ──────────────────
(define dream-ht-store (dream-todo-store))
((get dream-ht-store :add) "<script>alert(1)</script>")
(define
dream-ht-ctx
(assoc (dream-request "GET" "/" {} "") :dream-csrf {:sign dream-csrf-sign-default :sid "s1" :secret "k"}))
(define dream-ht-rendered (dr/todo-render dream-ht-store dream-ht-ctx))
(dream-ht-test
"todo escapes script"
(contains? dream-ht-rendered "&lt;script&gt;")
true)
(dream-ht-test
"todo has no raw script"
(contains? dream-ht-rendered "<script>")
false)
(define dream-ht-tests-run! (fn () {:total (+ dream-ht-pass dream-ht-fail) :passed dream-ht-pass :failed dream-ht-fail :fails dream-ht-fails}))

View File

@@ -136,6 +136,11 @@ with extensions + hardening below.
401 + `WWW-Authenticate: Basic realm=…`, attaches `:dream-user` on success;
`dream-basic-credentials` / `dream-authorization` accessors. `dream-require-bearer
check` → attaches `:dream-principal` or 401; `dream-bearer-token` accessor.
- **2026-06-07 — Ext: HTML escaping** (`lib/dream/html.sx`, 11 tests, 401 total).
`dream-escape` (&/</>/"/' entities, ampersand first to avoid double-escape),
`dream-attr`, `dream-escape-join`. Fixed a real **XSS hole** in the todo demo, which
interpolated user text into `<li>` unescaped — now `(dream-escape (get it :text))`;
regression test asserts `<script>` renders as `&lt;script&gt;`. 16 suites, 401/401.
## Extensions (post-roadmap)
@@ -151,6 +156,7 @@ The five-types core is complete; these harden it toward a production HTTP front
- [x] **Query/header convenience** (`dream-queries`, `*-or` defaults, `dream-accepts?`).
- [x] **`api.sx` facade + README** — `dream-make-app` / `dream-serve` + `README.md`.
- [x] **Auth** — base64 (pure SX), HTTP Basic auth + Bearer-token middleware.
- [x] **HTML escaping** (`dream-escape`/`dream-attr`) — fixed an XSS hole in the todo demo.
## Stdlib additions Dream will need