From bd1e78c40f0da6bbd7512ced6c0089540555931c Mon Sep 17 00:00:00 2001 From: giles Date: Sun, 7 Jun 2026 15:20:55 +0000 Subject: [PATCH] dream: security headers + cache-control middleware + 12 tests Co-Authored-By: Claude Opus 4.8 (1M context) --- lib/dream/conformance.sh | 2 + lib/dream/headers.sx | 54 ++++++++++++++++++++++ lib/dream/tests/headers.sx | 94 ++++++++++++++++++++++++++++++++++++++ plans/dream-on-sx.md | 8 ++++ 4 files changed, 158 insertions(+) create mode 100644 lib/dream/headers.sx create mode 100644 lib/dream/tests/headers.sx diff --git a/lib/dream/conformance.sh b/lib/dream/conformance.sh index 21ffd739..bba0c225 100644 --- a/lib/dream/conformance.sh +++ b/lib/dream/conformance.sh @@ -35,6 +35,7 @@ MODULES=( "lib/dream/json.sx" "lib/dream/auth.sx" "lib/dream/html.sx" + "lib/dream/headers.sx" "lib/dream/run.sx" "lib/dream/api.sx" "lib/dream/demos/hello.sx" @@ -58,6 +59,7 @@ SUITES=( "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" + "headers dream-hd-tests-run! lib/dream/tests/headers.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/headers.sx b/lib/dream/headers.sx new file mode 100644 index 00000000..2d1ec4d3 --- /dev/null +++ b/lib/dream/headers.sx @@ -0,0 +1,54 @@ +;; lib/dream/headers.sx — Dream-on-SX security headers + cache-control helpers. +;; Depends on types.sx. + +;; ── security headers middleware ──────────────────────────────────── +(define dream-security-defaults {:x-frame-options "DENY" :referrer-policy "no-referrer" :x-content-type-options "nosniff" :hsts false}) + +(define + dr/apply-security + (fn + (opts resp) + (let + ((r1 (dream-add-header (dream-add-header (dream-add-header resp "x-content-type-options" (get opts :x-content-type-options)) "x-frame-options" (get opts :x-frame-options)) "referrer-policy" (get opts :referrer-policy)))) + (if + (get opts :hsts) + (dream-add-header + r1 + "strict-transport-security" + "max-age=31536000; includeSubDomains") + r1)))) + +(define + dream-security-headers-with + (fn (opts) (fn (next) (fn (req) (dr/apply-security opts (next req)))))) +(define + dream-security-headers + (dream-security-headers-with dream-security-defaults)) + +;; ── cache-control response helpers ───────────────────────────────── +(define + dream-cache + (fn + (resp seconds) + (dream-add-header resp "cache-control" (str "public, max-age=" seconds)))) +(define + dream-private-cache + (fn + (resp seconds) + (dream-add-header resp "cache-control" (str "private, max-age=" seconds)))) +(define + dream-no-store + (fn (resp) (dream-add-header resp "cache-control" "no-store"))) +(define + dream-no-cache + (fn + (resp) + (dream-add-header + resp + "cache-control" + "no-cache, no-store, must-revalidate"))) + +;; cache-control middleware: stamp a max-age on every response +(define + dream-cache-for + (fn (seconds) (fn (next) (fn (req) (dream-cache (next req) seconds))))) diff --git a/lib/dream/tests/headers.sx b/lib/dream/tests/headers.sx new file mode 100644 index 00000000..cd81ad3f --- /dev/null +++ b/lib/dream/tests/headers.sx @@ -0,0 +1,94 @@ +;; lib/dream/tests/headers.sx — security headers + cache-control. + +(define dream-hd-pass 0) +(define dream-hd-fail 0) +(define dream-hd-fails (list)) + +(define + dream-hd-test + (fn + (name actual expected) + (if + (= actual expected) + (set! dream-hd-pass (+ dream-hd-pass 1)) + (begin + (set! dream-hd-fail (+ dream-hd-fail 1)) + (append! dream-hd-fails {:name name :actual actual :expected expected}))))) + +(define dream-hd-h (fn (req) (dream-text "body"))) +(define dream-hd-req (dream-request "GET" "/" {} "")) + +;; ── security headers ─────────────────────────────────────────────── +(define dream-hd-sec ((dream-security-headers dream-hd-h) dream-hd-req)) +(dream-hd-test + "nosniff" + (dream-resp-header dream-hd-sec "x-content-type-options") + "nosniff") +(dream-hd-test + "frame deny" + (dream-resp-header dream-hd-sec "x-frame-options") + "DENY") +(dream-hd-test + "referrer policy" + (dream-resp-header dream-hd-sec "referrer-policy") + "no-referrer") +(dream-hd-test + "no hsts by default" + (dream-resp-header dream-hd-sec "strict-transport-security") + nil) +(dream-hd-test "body preserved" (dream-resp-body dream-hd-sec) "body") + +(define + dream-hd-hsts + ((dream-security-headers-with (assoc dream-security-defaults :hsts true)) + dream-hd-h)) +(dream-hd-test + "hsts when enabled" + (contains? + (dream-resp-header + (dream-hd-hsts dream-hd-req) + "strict-transport-security") + "max-age=31536000") + true) + +;; ── cache-control ────────────────────────────────────────────────── +(dream-hd-test + "cache public" + (dream-resp-header + (dream-cache (dream-text "x") 60) + "cache-control") + "public, max-age=60") +(dream-hd-test + "private cache" + (dream-resp-header + (dream-private-cache (dream-text "x") 30) + "cache-control") + "private, max-age=30") +(dream-hd-test + "no-store" + (dream-resp-header (dream-no-store (dream-text "x")) "cache-control") + "no-store") +(dream-hd-test + "no-cache" + (dream-resp-header (dream-no-cache (dream-text "x")) "cache-control") + "no-cache, no-store, must-revalidate") + +;; ── cache middleware ─────────────────────────────────────────────── +(define dream-hd-capp ((dream-cache-for 300) dream-hd-h)) +(dream-hd-test + "cache-for stamps" + (dream-resp-header (dream-hd-capp dream-hd-req) "cache-control") + "public, max-age=300") + +;; ── composes around a router ─────────────────────────────────────── +(define + dream-hd-app + (dream-security-headers + (dream-router + (list (dream-get "/" (fn (req) (dream-html "

hi

"))))))) +(dream-hd-test + "router security header" + (dream-resp-header (dream-hd-app dream-hd-req) "x-frame-options") + "DENY") + +(define dream-hd-tests-run! (fn () {:total (+ dream-hd-pass dream-hd-fail) :passed dream-hd-pass :failed dream-hd-fail :fails dream-hd-fails})) diff --git a/plans/dream-on-sx.md b/plans/dream-on-sx.md index 043b14a7..75133d76 100644 --- a/plans/dream-on-sx.md +++ b/plans/dream-on-sx.md @@ -141,6 +141,13 @@ with extensions + hardening below. `dream-attr`, `dream-escape-join`. Fixed a real **XSS hole** in the todo demo, which interpolated user text into `
  • ` unescaped — now `(dream-escape (get it :text))`; regression test asserts `