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.
Quando esta spec se aplica
Triggers primários
- Add inline <script> or <style> to a templ surface
- Wire CSP middleware for a new Koder web app
Todos os triggers
- Add inline <script> or <style> to a Koder templ surface
- Build a new Koder web app with templ output
- Wire a CSP middleware for any Koder service that serves HTML
- Decide enforce-mode timing for an existing CSP middleware
- Audit a sibling Koder app for CSP parity with Flow
Corpo da especificação
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:
- 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 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:
- Accept both
application/csp-reportandapplication/reports+jsoncontent types. - Cap payload size to 64 KiB.
- Increment a violation counter labeled by directive:
<app>_csp_violation_total{directive}. In Koder Flow this iskoder_flow_csp_violation_total; sibling apps mirror the name. - Emit a structured WARN log line with
event=violation directive=… document_uri=… blocked_uri=… source=… line=…so operators can grep. - Be anonymous (no auth required) — CSP3 spec mandates this.
Reference impl: routers/web/csp_report.go → CSPReportHandler.
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.CSPdefaults remainEnabled=false/ReportOnly=true.- Operators flip in
app.ini(run the soak inREPORT_ONLY = truefirst — enforce-without-soak breaks the UI if any inline tag slipped the nonce; flip tofalseonly 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-reportDIRECTIVESMUST be backtick-quoted — the ini parser treats an unquoted;as an inline comment and silently truncates the value todefault-src 'self'(hit on the Koder Flow 2026-05-30 deploy). The middleware auto-injects'nonce-<value>'intoscript-src/style-srcper 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 zeroscript-srcsecuritypolicyviolationevents 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 hasnonce="{{.CSPNonce}}", but a partial included via a customdictreads.CSPNonceat dict scope where it is empty — thread it from$.root/ the page ctx). Flow ships this as the permanent e2e gatetests/e2e/kf-csp-coverage.test.e2e.ts(CI, every push); KDS shipsmake 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_ONLYon the fixed binary, with<app>_csp_violation_totalat 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. FlipREPORT_ONLY = falseonly 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:
Directive Share Root cause Fix 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 nonce move cspMiddlewarebeforeContexterstyle-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 Forgejoapp.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>.htmlpages, so its log looked clean — but a crawl of all 556 sitemap routes surfaced 44 pages whosesurfaceShowcasetab-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 shipsmake csp-nonce-auditas 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 livesites.tomlhad 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.inidoes NOT setIgnoreInlineComment, so a;in a value is treated as an inline comment and truncatesDIRECTIVESat the first directive (and double-quotes are not honored). CSP directives are semicolon-separated, so the wholeDIRECTIVESvalue 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 justdefault-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
DIRECTIVESdrops'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 bothscript-srcandstyle-srcdirectives. - T3: operator-set
'nonce-…'already inDIRECTIVESis 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 fromcrypto/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 carryingscript-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-ignoredframe-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's3qVGpPmuAttfs/T44I8fWQ==substitution turns the literal'nonce-3qVGpPmuAttfs/T44I8fWQ=='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-srcon a frontend that injects<style>from JS or usesstyle="…"attributes (R5.1). Keepstyle-src 'unsafe-inline'; lockscript-srcinstead. - ❌ Leaving federated Gravatar avatars on under a
self-onlyimg-src. Forgejo/Gitea default[picture] ENABLE_FEDERATED_AVATAR = trueloadssecure.gravatar.com/avatar/<email-hash>— both animg-srcviolation flood AND a per-page-view privacy leak of the user's email hash to a third party (Automattic). Self-hosted Koder apps setDISABLE_GRAVATAR = true+ENABLE_FEDERATED_AVATAR = falseso 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_settingtable:picture.disable_gravatar,picture.enable_federated_avatar). Theapp.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 othersystem_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.
Referências
policies/security.kmdproducts/dev/flow/engine/routers/web/csp_report.goproducts/dev/flow/engine/modules/setting/csp.goproducts/dev/flow/engine/build/lint-csp-nonce/main.go