dream: signed session cookies (tamper-evident sid) + 11 tests
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 37s
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 37s
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -72,6 +72,48 @@
|
||||
dream-drop-cookie
|
||||
(fn (resp name) (dream-set-cookie resp name "" {:max-age 0})))
|
||||
|
||||
;; ── signed cookie values (tamper-evident) ──────────────────────────
|
||||
;; NOTE: pure-SX keyed hash — not cryptographic; production should inject a host
|
||||
;; HMAC. Value carries no "." so the first "." splits value from signature.
|
||||
(define
|
||||
dr/sess-hash
|
||||
(fn (s) (dr/sess-hash-loop s 0 (string-length s) 7)))
|
||||
(define
|
||||
dr/sess-hash-loop
|
||||
(fn
|
||||
(s i n h)
|
||||
(if
|
||||
(>= i n)
|
||||
h
|
||||
(dr/sess-hash-loop
|
||||
s
|
||||
(+ i 1)
|
||||
n
|
||||
(mod (+ (* h 131) (char-code (char-at s i))) 2147483647)))))
|
||||
(define
|
||||
dr/sess-sig
|
||||
(fn (secret val) (str (dr/sess-hash (str secret "|" val)))))
|
||||
|
||||
(define
|
||||
dream-cookie-sign
|
||||
(fn (secret val) (str val "." (dr/sess-sig secret val))))
|
||||
(define
|
||||
dream-cookie-unsign
|
||||
(fn
|
||||
(secret signed)
|
||||
(if
|
||||
(or (nil? signed) (= signed ""))
|
||||
nil
|
||||
(let
|
||||
((dot (index-of signed ".")))
|
||||
(if
|
||||
(< dot 0)
|
||||
nil
|
||||
(let
|
||||
((val (substr signed 0 dot))
|
||||
(sig (substr signed (+ dot 1))))
|
||||
(if (= sig (dr/sess-sig secret val)) val nil)))))))
|
||||
|
||||
;; ── in-memory session store (tests + demos) ────────────────────────
|
||||
;; A backend is (fn (op) result) where op is a dict {:op ... :sid ... :key ...}.
|
||||
(define
|
||||
@@ -143,6 +185,32 @@
|
||||
sid
|
||||
{:path "/" :http-only true :same-site "Lax"}))))))))))
|
||||
|
||||
;; signed variant: the cookie value is signed so a guessed/forged sid is rejected
|
||||
(define
|
||||
dream-sessions-signed
|
||||
(fn
|
||||
(backend secret)
|
||||
(fn
|
||||
(next)
|
||||
(fn
|
||||
(req)
|
||||
(let
|
||||
((sid0 (dream-cookie-unsign secret (dream-cookie req dream-session-cookie-name))))
|
||||
(let
|
||||
((have (and sid0 (backend {:op "session/exists" :sid sid0}))))
|
||||
(let
|
||||
((sid (if have sid0 (backend {:op "session/create"}))))
|
||||
(let
|
||||
((resp (next (assoc req :dream-session {:io backend :sid sid}))))
|
||||
(if
|
||||
have
|
||||
resp
|
||||
(dream-set-cookie
|
||||
resp
|
||||
dream-session-cookie-name
|
||||
(dream-cookie-sign secret sid)
|
||||
{:path "/" :http-only true :same-site "Lax"}))))))))))
|
||||
|
||||
;; ── handler-facing session API ─────────────────────────────────────
|
||||
(define dr/session-of (fn (req) (get req :dream-session)))
|
||||
(define dream-session-id (fn (req) (get (dr/session-of req) :sid)))
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
;; lib/dream/tests/session.sx — cookies, store, session round-trip.
|
||||
;; lib/dream/tests/session.sx — cookies, store, session round-trip, signed cookies.
|
||||
|
||||
(define dream-ss-pass 0)
|
||||
(define dream-ss-fail 0)
|
||||
@@ -65,6 +65,19 @@
|
||||
"Max-Age=0")
|
||||
true)
|
||||
|
||||
;; ── signed cookie values ───────────────────────────────────────────
|
||||
(dream-ss-test
|
||||
"sign/unsign roundtrip"
|
||||
(dream-cookie-unsign "k" (dream-cookie-sign "k" "s5"))
|
||||
"s5")
|
||||
(dream-ss-test
|
||||
"unsign wrong secret"
|
||||
(dream-cookie-unsign "k2" (dream-cookie-sign "k" "s5"))
|
||||
nil)
|
||||
(dream-ss-test "unsign tampered" (dream-cookie-unsign "k" "s5.999") nil)
|
||||
(dream-ss-test "unsign no dot" (dream-cookie-unsign "k" "s5") nil)
|
||||
(dream-ss-test "unsign nil" (dream-cookie-unsign "k" nil) nil)
|
||||
|
||||
;; ── in-memory store ────────────────────────────────────────────────
|
||||
(define dream-ss-store (dream-memory-sessions))
|
||||
(define dream-ss-sid (dream-ss-store {:op "session/create"}))
|
||||
@@ -93,7 +106,6 @@
|
||||
(dream-text (str "count=" (+ n 1)))))))
|
||||
(define dream-ss-app ((dream-sessions dream-ss-backend) dream-ss-counter-h))
|
||||
|
||||
;; first request: no cookie -> creates session, sets cookie
|
||||
(define dream-ss-r1 (dream-ss-app (dream-request "GET" "/" {} "")))
|
||||
(dream-ss-test "first body count=1" (dream-resp-body dream-ss-r1) "count=1")
|
||||
(dream-ss-test
|
||||
@@ -109,7 +121,6 @@
|
||||
(contains? (first (dream-resp-cookies dream-ss-r1)) "HttpOnly")
|
||||
true)
|
||||
|
||||
;; second request: carries the cookie -> reuses, sees prior count, no new cookie
|
||||
(define dream-ss-r2 (dream-ss-app (dream-request "GET" "/" {:Cookie "dream.session=s1"} "")))
|
||||
(dream-ss-test "second body count=2" (dream-resp-body dream-ss-r2) "count=2")
|
||||
(dream-ss-test
|
||||
@@ -117,11 +128,9 @@
|
||||
(len (dream-resp-cookies dream-ss-r2))
|
||||
0)
|
||||
|
||||
;; third request continues
|
||||
(define dream-ss-r3 (dream-ss-app (dream-request "GET" "/" {:Cookie "dream.session=s1"} "")))
|
||||
(dream-ss-test "third body count=3" (dream-resp-body dream-ss-r3) "count=3")
|
||||
|
||||
;; unknown cookie id -> fresh session created
|
||||
(define dream-ss-r4 (dream-ss-app (dream-request "GET" "/" {:Cookie "dream.session=bogus"} "")))
|
||||
(dream-ss-test
|
||||
"bogus id starts fresh"
|
||||
@@ -133,11 +142,6 @@
|
||||
1)
|
||||
|
||||
;; ── session-all + invalidate via middleware ────────────────────────
|
||||
(define
|
||||
dream-ss-inspect-h
|
||||
(fn (req) (dream-text (str (dream-session-all req)))))
|
||||
(define dream-ss-app2 ((dream-sessions dream-ss-backend) dream-ss-inspect-h))
|
||||
(define dream-ss-r5 (dream-ss-app2 (dream-request "GET" "/" {:Cookie "dream.session=s1"} "")))
|
||||
(dream-ss-test
|
||||
"session-all shows count"
|
||||
(dream-session-all
|
||||
@@ -153,4 +157,41 @@
|
||||
(dream-ss-app3 (dream-request "GET" "/" {:Cookie "dream.session=s1"} ""))
|
||||
(dream-ss-test "invalidate clears store" (dream-ss-backend {:op "session/exists" :sid "s1"}) false)
|
||||
|
||||
;; ── signed session middleware ──────────────────────────────────────
|
||||
(define dream-ss-sbackend (dream-memory-sessions))
|
||||
(define
|
||||
dream-ss-sapp
|
||||
((dream-sessions-signed dream-ss-sbackend "topsecret")
|
||||
(fn (req) (dream-text (dream-session-id req)))))
|
||||
|
||||
(define dream-ss-sr1 (dream-ss-sapp (dream-request "GET" "/" {} "")))
|
||||
(dream-ss-test "signed first sid" (dream-resp-body dream-ss-sr1) "s1")
|
||||
(dream-ss-test
|
||||
"signed cookie is signed"
|
||||
(contains? (first (dream-resp-cookies dream-ss-sr1)) "dream.session=s1.")
|
||||
true)
|
||||
|
||||
;; forged plaintext sid (no signature) is rejected -> a fresh session is made
|
||||
(dream-ss-test
|
||||
"forged plaintext rejected -> new session"
|
||||
(dream-resp-body (dream-ss-sapp (dream-request "GET" "/" {:Cookie "dream.session=s1"} "")))
|
||||
"s2")
|
||||
|
||||
;; a validly-signed cookie reuses the session
|
||||
(define dream-ss-signed-val (dream-cookie-sign "topsecret" "s1"))
|
||||
(define dream-ss-sr3 (dream-ss-sapp (dream-request "GET" "/" {:Cookie (str "dream.session=" dream-ss-signed-val)} "")))
|
||||
(dream-ss-test "valid signed reuses s1" (dream-resp-body dream-ss-sr3) "s1")
|
||||
(dream-ss-test
|
||||
"valid signed sets no new cookie"
|
||||
(len (dream-resp-cookies dream-ss-sr3))
|
||||
0)
|
||||
|
||||
;; a cookie signed with the wrong secret is rejected
|
||||
(dream-ss-test
|
||||
"wrong-secret signed rejected"
|
||||
(=
|
||||
(dream-resp-body (dream-ss-sapp (dream-request "GET" "/" {:Cookie (str "dream.session=" (dream-cookie-sign "other" "s1"))} "")))
|
||||
"s1")
|
||||
false)
|
||||
|
||||
(define dream-ss-tests-run! (fn () {:total (+ dream-ss-pass dream-ss-fail) :passed dream-ss-pass :failed dream-ss-fail :fails dream-ss-fails}))
|
||||
|
||||
@@ -112,6 +112,13 @@ with extensions + hardening below.
|
||||
`dream-json-body` (parse request body). GOTCHA: `number?` is unreliable in this env —
|
||||
used `(= (type-of v) "number")`; `parse-float` handles decimals. Multi-key dict
|
||||
encode order follows `keys` (non-deterministic) so tests assert via parse round-trip.
|
||||
- **2026-06-07 — Ext: signed session cookies** (`lib/dream/session.sx`, session suite
|
||||
30→41, 340 total). The default store uses guessable sids (`s1`, `s2`), so
|
||||
`dream-sessions-signed backend secret` signs the cookie value (`sid.signature`) and
|
||||
rejects any cookie whose signature doesn't verify — a forged plaintext `s1` or a
|
||||
wrong-secret cookie yields a fresh session instead of a hijack. `dream-cookie-sign` /
|
||||
`dream-cookie-unsign` (keyed hash; same not-cryptographic caveat — inject a host HMAC
|
||||
in production). Plain `dream-sessions` unchanged for the no-secret case.
|
||||
|
||||
## Extensions (post-roadmap)
|
||||
|
||||
@@ -122,7 +129,7 @@ The five-types core is complete; these harden it toward a production HTTP front
|
||||
- [x] **Status reason phrases** + `dream-status-text` (`lib/dream/error.sx`).
|
||||
- [x] **CORS middleware** (`dream-cors`).
|
||||
- [x] **Error-handling middleware** (`dream-catch` / custom 500 templates; `guard`-based).
|
||||
- [ ] **Signed session cookies** (the noted hardening — sign the sid).
|
||||
- [x] **Signed session cookies** (`dream-sessions-signed` — tamper-evident sid).
|
||||
- [x] **JSON helpers** (encode + recursive-descent parse, pure SX).
|
||||
- [ ] **Query/header convenience** (`dream-queries`, defaults).
|
||||
- [ ] **`api.sx` facade + README** — single load point listing the public surface.
|
||||
|
||||
Reference in New Issue
Block a user