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.
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, 'unsafe-inline' and 'unsafe-eval'
are dropped — every inline tag 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.
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. - Monitor
<app>_csp_violation_totalfor a soak window of at least 7 days.
Soak posture: run the soak with
REPORT_ONLY = true(violations are reported, not blocked) so a missed nonce can't break the UI mid-soak. Only flipREPORT_ONLY = falseonce the window is clean. Validated on Koder Flow 2026-05-30 (FLOW-205).
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.
- 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. - ❌ 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.
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