Content-Security-Policy — canonical posture for Koder Flow + sibling apps

Draft mandatory

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:

  1. Generate a fresh 128-bit nonce per request (16 bytes from

    crypto/rand, base64-encoded). Reuse across requests is forbidden.

  2. Stash the nonce on the request context under a canonical key

    (setting.CSPNonceContextKey{} in Koder Flow; sibling apps mirror the location in their modules/setting/csp.go).

  3. Emit the Content-Security-Policy (or

    Content-Security-Policy-Report-Only) header with the nonce spliced into the script-src AND style-src directives as 'nonce-<value>'. Both directives are required — splicing only into script-src would block compliant inline <style> blocks.

  4. Refuse to overwrite an operator-supplied nonce already present

    in the configured DIRECTIVES.

Reference implementation: routers/web/csp_report.gocspMiddleware + 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 *.tmpl under 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 is

    reported 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}