Pular para o conteúdo

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

Todos os triggers

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:

  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.

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, '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.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.
  • Monitor <app>_csp_violation_total for 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 flip REPORT_ONLY = false once 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.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.
  • 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.
  • ❌ 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