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 "<") + "&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"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 "