Skip to content

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

security specs/security/csp.kmd

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.

When this spec applies

Primary triggers

All triggers

Specification body

Spec — Content-Security-Policy

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 templates/repo/{issue,diff,release,wiki}/*. Every one threads "CSPNonce" $.CSPNonce (or $.root.CSPNonce for diff/comment_form.tmpl).

When refactoring a partial to add an inline tag for the first time, the author MUST sweep every callsite to add the threading entry. The linter does not currently catch this — partial-dict analysis is harder than per-line regex — so the obligation is on the author. A future linter extension is permitted.

R4 — Report endpoint

When CSP is in report-only or enforce mode, the policy MUST declare report-uri (legacy CSP1 syntax) and SHOULD declare report-to (modern CSP3 syntax). The receiving endpoint MUST:

  1. Accept both application/csp-report and application/reports+json content types.
  2. Cap payload size to 64 KiB.
  3. Increment a violation counter labeled by directive: <app>_csp_violation_total{directive}. In Koder Flow this is koder_flow_csp_violation_total; sibling apps mirror the name.
  4. Emit a structured WARN log line with event=violation directive=… document_uri=… blocked_uri=… source=… line=… so operators can grep.
  5. Be anonymous (no auth required) — CSP3 spec mandates this.

Reference impl: routers/web/csp_report.goCSPReportHandler.

R5 — Default directive baseline

Before the enforce-mode flip (R6), the default baseline ships with:

default-src 'self';
script-src 'self' 'unsafe-inline' 'unsafe-eval';
style-src 'self' 'unsafe-inline';
img-src 'self' data: blob:;
font-src 'self' data:;
connect-src 'self';
frame-ancestors 'self';
base-uri 'self';
form-action 'self';

After the enforce-mode flip, script-src drops 'unsafe-inline' and 'unsafe-eval' — every inline <script> is gated by the per-request nonce. The default may additionally add 'strict-dynamic' to script-src so trusted scripts can load further scripts without each carrying its own nonce; this is operator-tunable.

R5.1 — style-src cannot go nonce-only on JS-rendered frontends

script-src and style-src are not symmetric at the flip. style-src may only drop 'unsafe-inline' when every style is server-rendered (so the nonce reaches it). A frontend that injects <style> elements at runtime from JS (Forgejo/Gitea's index.js does this for dropdowns, tooltips, web components) or uses inline style="…" attributes (which cannot carry a nonce at all — CSP3 style-src-attr has no nonce mechanism) will emit style-src-elem="inline" / style-src-attr violations forever, no matter how clean the templ-side nonce coverage is.

For such frontends the ratified enforce baseline is asymmetric:

script-src 'self' 'nonce-<value>';        ; strict — the XSS win
style-src  'self' 'unsafe-inline';         ; pragmatic — JS-injected styles + attrs

This is the posture GitHub itself ships, and it is the correct trade-off: script injection is the high-severity vector (arbitrary code execution); style injection is low-severity (layout/exfil-by- CSS at most). Locking script-src to a nonce captures ~all of the CSP value; chasing a nonce-only style-src on a JS-heavy frontend buys little and breaks the UI. Note the nonce is dropped from style-src here, not combined with 'unsafe-inline' — CSP3 ignores a nonce when 'unsafe-inline' is present (see Anti-patterns), so keeping both would be a no-op that falsely reads as "nonce-protected".

A frontend whose styles are all server-rendered (templ-only, no runtime <style> injection, no style= attrs) MAY go nonce-only on style-src — verify with a full-corpus crawl + a runtime headless load before claiming it.

R6 — Staged enforce-mode flip

The flip from report-only to enforce is staged across two releases to give operators a soak window:

Release N — opt-in enforce

  • setting.CSP defaults remain Enabled=false/ReportOnly=true.
  • Operators flip in app.ini (run the soak in REPORT_ONLY = true first — enforce-without-soak breaks the UI if any inline tag slipped the nonce; flip to false only after the soak is clean):
    [security.csp]
    ENABLED     = true
    REPORT_ONLY = true
    DIRECTIVES  = `default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data: blob:; font-src 'self' data:; connect-src 'self'; frame-ancestors 'self'; base-uri 'self'; form-action 'self'`
    REPORT_URI  = /-/csp-report
    
    DIRECTIVES MUST be backtick-quoted — the ini parser treats an unquoted ; as an inline comment and silently truncates the value to default-src 'self' (hit on the Koder Flow 2026-05-30 deploy). The middleware auto-injects 'nonce-<value>' into script-src/style-src per request (R1), so the operator value omits the nonce.

R6.1 — Enforce-readiness gate (coverage, NOT a calendar soak)

The flip to REPORT_ONLY = false is gated on coverage, not on a passive calendar window. A long soak on a low-traffic surface is a weak signal — its <app>_csp_violation_total reads zero because organic traffic never exercised the route, not because the route is clean (proven twice: the KDS /style/* pages, jet#160; and Koder Flow's repo home/commits/releases, FLOW-205 2026-06-03, where the branch-tag selector's inline <script type=module> shipped nonce-less for weeks while the prod soak counter sat at zero). The gate is G1–G5:

  • G1 — static nonce linter green (R2.1): every inline <script>/<style> in the source templates carries the nonce attribute.
  • G2 — deployed-template audit: any on-disk/custom template that overrides the bundled asset (and so bypasses G1) carries the nonce too. (Flow ships custom/templates/**; these are not in the linter's source tree.)
  • G3 — deterministic route-coverage crawl under the production enforce-equivalent policy in REPORT_ONLY, asserting zero script-src securitypolicyviolation events across one entry per route/template family — authenticated + anon. This is the load-bearing gate: it catches the runtime-empty-nonce class G1 cannot (the template text has nonce="{{.CSPNonce}}", but a partial included via a custom dict reads .CSPNonce at dict scope where it is empty — thread it from $.root / the page ctx). Flow ships this as the permanent e2e gate tests/e2e/kf-csp-coverage.test.e2e.ts (CI, every push); KDS ships make csp-nonce-audit + a headless crawl of the wasm/runtime-heavy pages.
  • G4 — live-header diff: the deployed response header is diffed against the source baseline before the flip (catches sites-drift / a silently dropped directive like KDS's 'wasm-unsafe-eval', jet#160).
  • G5 — bounded prod window ≥ 24h in REPORT_ONLY on the fixed binary, with <app>_csp_violation_total at zero. This covers content variance (user-generated wiki/markdown a synthetic crawl can't enumerate) — NOT route coverage, which G3 already proved. 24h spans a full daily usage cycle; extend only if the counter is non-zero (triage, don't wait blindly).

G1+G2+G3+G4 are deterministic (minutes); only G5 is wall-clock, and 24h — not 7 days — because G3 carries the route-coverage burden the old soak leaned on.

Soak posture: the G5 window runs with REPORT_ONLY = true (violations reported, not blocked) so a missed nonce can't break the UI. Flip REPORT_ONLY = false only after G1–G5 are all green on a deployed binary carrying every nonce fix — a clean counter against an unfixed binary is the false-negative G3 exists to kill.

Worked example — Koder Flow (flow.koder.dev), 2026-06-01 (FLOW-205 soak triage). The report-only soak did its job: it was NOT clean, and flipping on the "violations→0" assumption would have broken the UI. The ~7.7k accumulated violations broke down into three distinct root causes, none a templ-author miss:

DirectiveShareRoot causeFix
img-src~74%federated Gravatar avatars (default on)disable federation → local avatars (Anti-patterns)
script-src-elem="inline"~19%middleware ordering bug (R1.1) — header nonce ≠ template noncemove cspMiddleware before Contexter
style-src-{elem,attr}~7%JS-injected <style> + style= attrs (R5.1)keep style-src 'unsafe-inline'

Lessons: (1) a directive-only counter is not enough to triage — the structured per-violation log (blocked_uri, source) is what distinguished "Gravatar" from "inline" from "JS-injected"; keep it. (2) The enforce flip is gated on the three fixes above landing in a deployed binary + config, NOT merely on the soak counter reaching zero against the buggy binary (which it never could — the ordering bug guarantees a permanent inline-script violation stream). Re-soak after the fixed binary deploys.

Worked example — KDS (kds.koder.dev / koder.design), 2026-05-31 (jet#160). Different stack from Flow (Koder Jet nonce middleware + design-gen templ-authored nonces, not Forgejo app.ini) but the same staged report-only → enforce discipline. Lesson: an exhaustive nonce-coverage crawl of every route beats a passive soak window. The 7-day report-only soak's organic traffic had NOT yet exercised the /style/<preset>.html pages, so its log looked clean — but a crawl of all 556 sitemap routes surfaced 44 pages whose surfaceShowcase tab-switcher carried a bare inline <script> (same missed-nonce class as the root index and layout/kinds). Flipping on the passive log alone would have broken every style-preview page. Before declaring an enforce flip safe, crawl the whole corpus for inline <script>/<style> without a matching nonce (design-gen ships make csp-nonce-audit as the standing structural guard) AND headless-load the wasm/runtime-heavy pages (KVG, XR) to catch runtime-injected inline scripts a static crawl misses. The live header MUST also be diffed against the source template before the flip — the KDS live sites.toml had silently dropped 'wasm-unsafe-eval' (a jet sites-drift recurrence), which would have broken the wasm renderer on enforce.

app.ini quoting GOTCHA (load-bearing). The Forgejo/Gitea ini loader used for app.ini does NOT set IgnoreInlineComment, so a ; in a value is treated as an inline comment and truncates DIRECTIVES at the first directive (and double-quotes are not honored). CSP directives are semicolon-separated, so the whole DIRECTIVES value MUST be wrapped in backticks:

DIRECTIVES = `default-src 'self'; script-src 'self'; style-src 'self'; …`

Verify after restart that the live Content-Security-Policy[-Report-Only] header contains ALL directives (not just default-src).

Release N+1 — enforce by default

After the soak window produces zero false positives in production:

  • Default setting.CSP.Enabled = true.
  • Default setting.CSP.ReportOnly = false.
  • Default DIRECTIVES drops 'unsafe-inline' + 'unsafe-eval'.
  • A pre-flip canary test (one templ surface without the nonce) surfaces in the violation counter — operators verify the counter+log emission before declaring the flip safe.

The flip itself is a config default change, not a feature flag. Operators with custom DIRECTIVES continue to control their own policy; only those relying on defaults pick up the tighter baseline.

R7 — securityheaders.com target

After the flip, each Koder web app SHOULD score ≥ A on securityheaders.com (or local equivalent). The baseline grade before and after each app's flip is recorded in meta/context/registries/security-baseline.md.

T1–T7 — Test obligations

Components implementing this spec MUST ship the following tests:

  • T1: middleware generates a non-empty nonce per request, spliced into the response header.
  • T1.1: the nonce the template-data builder reads from the request context is non-empty and byte-identical to the header nonce (the R1.1 ordering contract). A reversed middleware order yields an empty template nonce — pin both directions so the ordering can't silently regress. Ref: routers/web/csp_middleware_test.go (Koder Flow).
  • T2: cspApplyNonce (or equivalent) splices into both script-src and style-src directives.
  • T3: operator-set 'nonce-…' already in DIRECTIVES is not overwritten.
  • T4: every inline <script> and <style> in the templ tree carries the nonce attribute (templ-side source-pin).
  • T5: the templ linter rejects an unadorned inline tag.
  • T6: report endpoint accepts both content types, increments the counter, and emits the structured log.
  • T7: after the R6 default change, a canary unadorned <script> surfaces in the violation counter.

Anti-patterns

  • ❌ Manually inserting a hard-coded nonce="abc123" in a templ. The nonce MUST be per-request from crypto/rand.
  • ❌ Setting script-src 'unsafe-inline' 'nonce-…' simultaneously — CSP3 ignores the nonce when 'unsafe-inline' is present, so the policy is no tighter than before.
  • ❌ Shipping two CSP delivery channels that disagree — an in-page <meta http-equiv="Content-Security-Policy"> and the response header. Browsers enforce the intersection of all policies, so a meta carrying script-src 'unsafe-inline' does NOT weaken a nonce-based header (the nonce still gates inline scripts) — but the meta's 'unsafe-inline' is redundant and misleading: an auditor reading the page source reads the page as weaker than it is. The response header is the single authoritative source (it alone carries per-request nonce, report-uri, and the meta-ignored frame-ancestors). Deliver CSP only via the header; retire any in-page <meta> CSP once the header is guaranteed on every serving surface (policies/web-server.kmd — Koder Jet is the only web server, so the header is always present). Worked example: design-gen #182 / jet#201 retired the KDS <meta> CSP after the Jet header flipped to enforce. Footgun if you instead mirror the nonce into the meta: a surface that serves the HTML without the header's 9N97N5tADuVaZXaowtJHYQ== substitution turns the literal 'nonce-9N97N5tADuVaZXaowtJHYQ==' into an invalid nonce that blocks the page's own inline scripts.
  • ❌ Skipping the report endpoint. Without violation telemetry the flip is blind.
  • ❌ Per-partial nonce regeneration. The whole response shares one nonce; partials thread the root nonce.
  • ❌ Registering the nonce middleware after the template-data builder (R1.1). The header gets a nonce the templates never see; inline tags render unnonced and break at the enforce flip.
  • ❌ Chasing a nonce-only style-src on a frontend that injects <style> from JS or uses style="…" attributes (R5.1). Keep style-src 'unsafe-inline'; lock script-src instead.
  • ❌ Leaving federated Gravatar avatars on under a self-only img-src. Forgejo/Gitea default [picture] ENABLE_FEDERATED_AVATAR = true loads secure.gravatar.com/avatar/<email-hash> — both an img-src violation flood AND a per-page-view privacy leak of the user's email hash to a third party (Automattic). Self-hosted Koder apps set DISABLE_GRAVATAR = true + ENABLE_FEDERATED_AVATAR = false so avatars are served locally from 'self' (policies/self-hosted-first.kmd, policies/identity-data-retention.kmd). GOTCHA (FLOW-205): on Forgejo/Gitea these two keys are DB-backed (system_setting table: picture.disable_gravatar, picture.enable_federated_avatar). The app.ini [picture] value is only the first-init DEFAULT — once the row exists, the DB value WINS and editing app.ini does nothing. Flip it in the DB (UPDATE system_setting SET setting_value='true' WHERE setting_key='picture.disable_gravatar') + restart, or via the admin UI. Verify by rendering a page with commit-author avatars (resolved by email hash) — these are the heaviest gravatar source, not just registered-user avatars. Same DB-overrides-config trap applies to other system_setting-backed Gitea keys.

Maturity

v0.1 Draft — codifies the FLOW-177 / FLOW-190 / FLOW-202 / FLOW-204 / FLOW-205 implementations in Koder Flow. Promote to v1.0 Ratified after the first sibling Koder app (Hub or KDS site) ships R1–R5 + 7-day soak with zero violations.

References