diff --git a/lib/dream/conformance.sh b/lib/dream/conformance.sh
index 2aae5fca..21ffd739 100644
--- a/lib/dream/conformance.sh
+++ b/lib/dream/conformance.sh
@@ -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"
diff --git a/lib/dream/demos/todo.sx b/lib/dream/demos/todo.sx
index 72317b22..ab367199 100644
--- a/lib/dream/demos/todo.sx
+++ b/lib/dream/demos/todo.sx
@@ -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
"
"
(if (get it :done) "[x] " "[ ] ")
- (get it :text)
+ (dream-escape (get it :text))
""))
""
((get store :all)))
diff --git a/lib/dream/html.sx b/lib/dream/html.sx
new file mode 100644
index 00000000..feeec7a8
--- /dev/null
+++ b/lib/dream/html.sx
@@ -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 "&" "&") "<" "<") ">" ">")
+ "\""
+ """)
+ "'"
+ "'")))
+
+;; 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))))
diff --git a/lib/dream/tests/html.sx b/lib/dream/tests/html.sx
new file mode 100644
index 00000000..bf76b7bb
--- /dev/null
+++ b/lib/dream/tests/html.sx
@@ -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 & b")
+(dream-ht-test "escape lt gt" (dream-escape "") "<b>")
+(dream-ht-test "escape quote" (dream-escape "say \"hi\"") "say "hi"")
+(dream-ht-test "escape apostrophe" (dream-escape "it's") "it's")
+(dream-ht-test
+ "escape script tag"
+ (dream-escape "")
+ "<script>alert(1)</script>")
+(dream-ht-test
+ "ampersand first (no double-escape)"
+ (dream-escape "<")
+ "<")
+(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"b\"")
+(dream-ht-test
+ "escape-join"
+ (dream-escape-join " " (list "" ""))
+ "<a> <b>")
+
+;; ── todo demo escapes user input (XSS regression) ──────────────────
+(define dream-ht-store (dream-todo-store))
+((get dream-ht-store :add) "")
+(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 "<script>")
+ true)
+(dream-ht-test
+ "todo has no raw script"
+ (contains? dream-ht-rendered "