Date: 2026-06-08 Ticket: BOU-56 (parent BOU-54, blocks BOU-57) Status: approved (brainstorm), pending implementation plan
BOU-56 was written to "replace a CSRF stub, wire ring.middleware.anti-forgery/wrap-anti-forgery,
and make enforcement opt-in." That premise is stale.
Commit 8956168 — BOU-43 (PR #170): "real CSRF protection for session-authenticated
requests", already merged to main — replaced the stub with a complete, tested custom
implementation:
libs/platform/src/boundary/platform/core/csrf.clj — pure HMAC-SHA256 synchronizer-token
generation + validation, constant-time compare via buddy mac/verify.http-csrf-protection interceptor (interceptors.clj ~L392) — wired into
default-http-interceptors. Validates state-changing requests, auto-issues tokens, binds
csrf/*token* for rendering./web flows (login / register / MFA).x-csrf-token header extraction; <meta> tag + init.js listener for HTMX.security_test.clj and csrf_test.clj.The ticket's line references (valid-csrf-token? ~L55, http-csrf-protection ~L341) do not
match the current tree, confirming it was authored against the pre-BOU-43 state. The branch
feat/BOU-56-CSRF-validation carries only this design commit ahead of main — no BOU-56 code
work started.
Decision (brainstorm): keep the BOU-43 custom implementation. Do not switch to stock
wrap-anti-forgery (it cannot express the pre-session login binding the custom impl provides,
and the switch would delete working, tested code). Reconcile only the genuine remaining deltas.
mainenabled? true in two places, so a version bump
would 403 any consumer rendering /web POST forms without tokens. BOU-56 #3 and the sibling
BOU-57 ("emit tokens + enable enforcement") describe an opt-in model: the framework ships
default-off; each consumer migrates by emitting tokens, then flipping enforcement on.hx-headers helper. Only hidden-field
(forms) + the <meta>/init.js path (HTMX) exist today. A self-contained hx-headers helper
is wanted so consumers needn't depend on the global meta/JS listener.Already correct — no code change, documented for the record:
/web POSTs are protected (web-route? matches the /web prefix, covering /web/admin;
asserted by security_test). The ticket's "non-admin only" worry was pre-BOU-43.exempt-paths).The default lives in two places; both must change or "default off" is only half-true.
libs/platform/src/boundary/platform/shell/system/wiring.clj ~L317: the merge default
:enabled? true → :enabled? false. Update the adjacent comment to state the lib ships
opt-in and consumers must set :enabled? true after emitting tokens.libs/platform/src/boundary/platform/shell/http/interceptors.clj ~L418: the destructure
:or {enabled? true} → :or {enabled? false}. This is the fallback when a :csrf map omits
:enabled?; it must also default off for true opt-in.The fail-loud startup WARN (wiring.clj ~L323, fires only when enabled + blank secret) stays valid.
This repo is itself a platform consumer. Today prod/acc carry no :csrf config and no
:boundary/http block at all — their HTTP + CSRF settings come entirely from the wiring
defaults, so they currently inherit :enabled? true + :secret (System/getenv "JWT_SECRET").
The moment A1 flips the wiring default to false, prod/acc silently drop from default-on to
default-off. That causal chain is exactly why the explicit block below is load-bearing, not tidying.
resources/conf/prod/config.edn — create a :boundary/http key under :active (none
exists today) containing :security {:csrf {:enabled? true :secret #env JWT_SECRET}}
(no literal fallback — prod must fail loud without a secret).resources/conf/acc/config.edn — same (verify in plan whether acc already has a :boundary/http
block; create or extend accordingly).resources/conf/dev/config.edn — already has :boundary/http {:security {:csrf {:enabled? true …}}};
no change.resources/conf/test/config.edn — stays :enabled? false; no change.Merge behavior (verified, safe). csrf-config is read via (get-in config [:active :boundary/http :security :csrf]), independent of other HTTP settings. Port/host/join? are read on
a separate path — src/boundary/config.clj http-config uses per-key or fallbacks
((or (:port http-cfg) 3000), etc.) — so a partial :boundary/http block carrying only
:security is additive: each missing HTTP key independently falls back to its default. There
is no "authoritative block" hazard; adding only :security cannot shadow port/host/join. No extra
HTTP keys required.
Note: prod/acc also define no :boundary/router/:boundary/admin, suggesting these env configs
are deployment templates not fully exercised as the live server config in this repo. The
load-bearing change is the CSRF :enabled?/:secret flag; port/host are moot either way.
hx-headers helperIn libs/platform/src/boundary/platform/core/csrf.clj, add a helper parallel to hidden-field:
(defn hx-headers
"HTMX attribute fragment carrying the CSRF token, for elements that should send
it without relying on the global <meta>/init.js listener. Merge into an element's
attribute map (e.g. on <body>) so all inherited hx-* requests include the header.
0-arity reads the token bound for the current request (*token*); 1-arity takes an
explicit token. Returns nil when the token is nil, so callers can merge it
unconditionally."
([] (hx-headers *token*))
([token]
(when token
{:hx-headers (cheshire.core/generate-string {header-name token})})))
{:hx-headers "..."}) or nil — distinct from
hidden-field, which returns a Hiccup element. Call site: [:body (merge attrs (csrf/hx-headers)) ...].header-name constant ("x-csrf-token"); the interceptor's
extract-token already reads that header. (Case: HTTP headers are case-insensitive and Ring
lowercases them, so "x-csrf-token" is consistent with the documented X-CSRF-Token.)cheshire.core/generate-string. cheshire is a declared platform dep and is already
required from core/http/problem_details.clj, so this respects FC/IS (pure string encoding,
no I/O). Add the [cheshire.core :as json] require to the csrf.clj ns form.libs/platform/test/boundary/platform/core/csrf_test.clj:
hx-headers 1-arity returns {:hx-headers <json>} whose JSON parses to
{"x-csrf-token" <token>}.hx-headers with nil token returns nil.hx-headers 0-arity reads *token* under binding.libs/platform/test/boundary/platform/shell/security_test.clj:
:csrf map with :secret present but no :enabled? key
→ a state-changing POST is not validated (interceptor no-ops). Proves both default sites are
off. Existing explicit-:enabled? true tests remain unchanged and must still pass.csrf.clj ns header and the http-csrf-protection docstring to state
enforcement is opt-in (default off) and how to enable.libs/platform/AGENTS.md: short CSRF section — opt-in default, hidden-field for forms,
hx-headers vs <meta>/init.js for HTMX, config key path, API-route exclusion.Rewrite the description to reflect reality:
wrap-anti-forgery" framing.hx-headers helper + correct the admin/API notes (already handled).core/csrf.clj, current interceptor location).Rewrite to the real API:
hx-headers helper.boundary.platform.core.csrf/hidden-field for forms,
boundary.platform.core.csrf/hx-headers merged onto <body> for HTMX (not stock
*anti-forgery-token* / wrap-anti-forgery), config path
:boundary/http {:security {:csrf {:enabled? true :secret …}}}.BOU-57's code (separate boundary-license repo) is out of scope for this branch.
The stable contract BOU-57 (and any consumer) integrates against:
| Concern | Contract |
|---|---|
| Namespace | boundary.platform.core.csrf |
| Enable | config :boundary/http {:security {:csrf {:enabled? true :secret <≥32 chars>}}} — lib default is off; consumer must set on |
| Forms | splice (csrf/hidden-field) into each /web POST form (0-arity reads bound *token*; nil-safe) |
| HTMX | (csrf/hx-headers) merged onto <body> attrs (inherits to all hx-*); or <meta name="csrf-token" :content (csrf/current-token)> + ui-style init.js listener |
| Token lifecycle | interceptor auto-issues + binds *token* for /web (session binding when authed; pre-session cookie for login/register/MFA). No handler threading |
| API routes | untouched — token-auth/JWT, no session cookie → not validated. Do not add tokens |
| Reject rule | state-changing POST/PUT/DELETE/PATCH on /web or session-authenticated → 403 without a valid token |
ring/ring-anti-forgery dep~~ — no-op: verified ring/ring-anti-forgery is
not a declared dependency anywhere in the repo (platform declares only ring/ring-core +
ring/ring-jetty-adapter). The custom impl never depended on stock middleware; nothing to remove.
The ticket's "declared dep" claim was part of the same stale premise.clojure -M:test:db/h2 :platform green, including new hx-headers + opt-in assertions.:enabled? true).bb check:fcis passes (cheshire in core already precedented; helper is pure).clojure -M:clj-kondo --lint clean on changed files.Can you improve this documentation?Edit on GitHub
cljdoc builds & hosts documentation for Clojure/Script libraries
| Ctrl+k | Jump to recent docs |
| ← | Move to previous article |
| → | Move to next article |
| Ctrl+/ | Jump to the search field |