Content-Security-Policy — canonical posture for Koder Flow + sibling apps
Normative CSP posture for Koder Flow and every sibling Koder web surface (Hub, ID, KDS site, landings). Codifies the per-request nonce pipeline, the templ-author contract, partial-template threading rules, the report-uri/report-to obligations, the default-directive baseline, and the staged enforce-mode flip. Reference implementation lives in Koder Flow (FLOW-177 / FLOW-190 / FLOW-202 / FLOW-204 / FLOW-205); other apps adopt the same shape.
CSP is the canonical mitigation for client-side script injection in Koder Flow and every sibling web app. This spec codifies the wire contract, the templ-author obligations, and the staged enforce-mode flip.
R1 — Per-request nonce generation
Every request that returns an Content-Type: text/html response MUST:
- Generate a fresh 128-bit nonce per request (16 bytes from
crypto/rand, base64-encoded). Reuse across requests is forbidden. - Stash the nonce on the request context under a canonical key
(
setting.CSPNonceContextKey{}in Koder Flow; sibling apps mirror the location in theirmodules/setting/csp.go). - Emit the
Content-Security-Policy(orContent-Security-Policy-Report-Only) header with the nonce spliced into thescript-srcANDstyle-srcdirectives as'nonce-<value>'. Both directives are required — splicing only intoscript-srcwould block compliant inline<style>blocks. - Refuse to overwrite an operator-supplied nonce already present
in the configured
DIRECTIVES.
Reference implementation: routers/web/csp_report.go → cspMiddleware + cspApplyNonce + injectNonceIntoDirective.
R1.1 — Middleware ordering: nonce MUST precede the template-data builder
The nonce-generating middleware MUST run before whatever middleware copies the nonce out of the request context into the template render data (ctx.Data["CSPNonce"] in Koder Flow, populated by services/context.Contexter). Middleware chains apply outermost-first, so the nonce middleware must be registered earlier in the chain.
If the template-data builder runs first, it reads an empty nonce from the context (the nonce middleware hasn't run yet), so the template variable is "" and every <script{{if .CSPNonce}} nonce="…"{{end}}> renders unnonced — while the response header (set later by the nonce middleware) still carries a real nonce. Header and template disagree, so under enforce-mode the browser blocks every server- rendered inline tag even though the templ source is correct. This is silent under report-only (it just inflates script-src-elem="inline" / style-src-elem="inline" counts) and only bites at the flip.
This was the FLOW-205 root cause: cspMiddleware was registered after context.Contexter() in routers/web/web.go. Fixed by moving cspMiddleware ahead of Contexter. Regression guard: routers/web/csp_middleware_test.go (asserts the nonce the Contexter sees is non-empty and byte-identical to the header nonce, and that the reversed order reproduces the empty-template-nonce bug). See T1.1.
R2 — Templ author contract
Every inline <script> and every inline <style> opening tag in templates*.tmpl MUST carry the nonce attribute. Preferred form (emits no attribute when CSP is disabled — keeps validators happy under the legacy unsafe-inline baseline):
<script{{if .CSPNonce}} nonce="{{.CSPNonce}}"{{end}}>
<style{{if .CSPNonce}} nonce="{{.CSPNonce}}"{{end}}>External-src <script> blocks (<script src="/js/app.js">) MUST also carry the nonce when CSP enforce-mode uses 'strict-dynamic' — the nonce gates external script loads, not just inline bodies.
R2.1 — Linter enforcement
A build-time linter walks the templ tree and fails CI when an inline <script> or <style> opening tag lacks the nonce attribute. Reference impl: build/lint-csp-nonce/main.go.
The linter MUST:
- Walk every
*.tmplunder the templ root. - Skip
templates/mail/(SMTP delivery is out of CSP scope). - Skip swagger / OpenAPI HTML descriptors.
- Flag both
<script>and<style>openings (the kind label isreported in the failure message).
- Document the preferred form in its failure message so operators
copy-paste the canonical pattern.
R3 — Partial-template threading
Partials invoked via dict (...) only see the named keys — .CSPNonce from the root scope is NOT carried through. Every partial that contains an inline <script> or <style> MUST be called with a "CSPNonce" $.CSPNonce entry in its dict (or $.root.CSPNonce when the caller nests data under .root).
Reference impl: Koder Flow's combomarkdowneditor.tmpl is the canonical case study — it ships an inline boot <script> and has 9 dict callsites in `templatesrepo{issue,diff,release,wiki}