CSP tripwire: source-scan test + AGENTS.md rule; drop last native onchange
templates/csp_source_test.go scans .templ sources and fails the build on the three authoring patterns hash-mode CSP refuses silently: native on*= handler attributes (quote and templ brace forms), inline <script> content outside the sanctioned theme_flash_guard.templ, and htmx's eval-dependent bindings (hx-on*, js:-prefixed hx-vals — 'unsafe-eval' is not granted). Verified to fire on all four violation classes via a scratch probe file. The dry run immediately caught a live leftover: unlinked_books.templ's onchange="updateSelectedCount()" — CSP-refused since the hash flip. Removed; the count already updates through the document-level delegated change listener that x-init="setupEventDelegation()" registers. AGENTS.md gains a "CSP discipline" ALWAYS-apply section documenting the hash allow-list, the sanctioned-guard invariant, the replacement patterns, and the tripwire test. Verified: go build/vet, templates+middleware+docs tests green; /unlinked- books loads with zero console violations (no fixture rows to exercise the checkbox; the delegated path is unchanged code).
This commit is contained in:
1 parent
56dbca37d0
commit
4681e98766
4 files changed
+119
-2
No files matched your search
@@ -41,6 +41,38 @@ with heavy interactivity. First-paint section markup is still templ
|
|||||||
deliberate; do not clone it to new pages, and do not "fix" the dashboard
|
deliberate; do not clone it to new pages, and do not "fix" the dashboard
|
||||||
without restoring a templ-partial fade path first.
|
without restoring a templ-partial fade path first.
|
||||||
|
|
||||||
|
## CSP discipline (hash-allow-listed script-src) — ALWAYS apply
|
||||||
|
|
||||||
|
script-src carries a sha256 hash, not `'unsafe-inline'` (and never
|
||||||
|
`'unsafe-eval'` — the Alpine build is `@alpinejs/csp` for exactly this
|
||||||
|
reason; keep it in lockstep with alpinejs). The browser therefore
|
||||||
|
refuses, **silently at build/test time**, two authoring patterns:
|
||||||
|
|
||||||
|
- **No inline `<script>` content outside `theme_flash_guard.templ`.**
|
||||||
|
The guard is the app's only inline script; its bytes are pinned by
|
||||||
|
`middleware.ScriptHashThemeFlashGuard` + the drift test. Editing the
|
||||||
|
guard means recomputing the hash (the failing test prints it); adding
|
||||||
|
a *second* inline script means hashing it too and extending
|
||||||
|
`cspSanctionedInlineScripts` in `templates/csp_source_test.go`.
|
||||||
|
Everything else goes in a bundled `web/src` module.
|
||||||
|
- **No native `on*=` handler attributes** (`onclick=`, `onerror=`,
|
||||||
|
`onsubmit=`, `onchange=`, … — quote AND templ brace forms). Inline
|
||||||
|
handlers never go through the hash path; hash-mode CSP refuses them
|
||||||
|
unconditionally. Use Alpine `@click`/`@change`/… bound to component
|
||||||
|
methods (see the 2026-10 CSP-build conversion commits for the
|
||||||
|
pattern), or delegated listeners for htmx-swapped content.
|
||||||
|
- **No htmx eval-dependent bindings** (`hx-on*`, `js:`-prefixed
|
||||||
|
`hx-vals`) — `'unsafe-eval'` is not granted.
|
||||||
|
|
||||||
|
`templates/csp_source_test.go` is the tripwire for all three (it
|
||||||
|
scans the `.templ` sources; it runs in seconds on host). `style-src
|
||||||
|
'unsafe-inline'` remains by design (htmx-injected styles, Alpine
|
||||||
|
`:style`); `'self'` still trusts the origin not to serve
|
||||||
|
attacker-supplied scripts — don't let upload paths become
|
||||||
|
script-executable. Remember: `templ` does not interpolate `{ expr }`
|
||||||
|
inside quoted attribute values (gotcha #1 below), so dynamic
|
||||||
|
`@click={ "fn('" + id + "')" }` attribute values must BE expressions.
|
||||||
|
|
||||||
## Library visibility (family-lens rule) — ALWAYS apply
|
## Library visibility (family-lens rule) — ALWAYS apply
|
||||||
|
|
||||||
Per-user library visibility (`library_visibility` table, default-ALLOW:
|
Per-user library visibility (`library_visibility` table, default-ALLOW:
|
||||||
|
|||||||
@@ -0,0 +1,85 @@
|
|||||||
|
package templates
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// CSP tripwire.
|
||||||
|
//
|
||||||
|
// script-src is hash-allow-listed (no 'unsafe-inline' — see
|
||||||
|
// internal/middleware/security_headers.go). That makes the browser refuse,
|
||||||
|
// silently at build time, two authoring patterns:
|
||||||
|
//
|
||||||
|
// 1. Inline <script> content whose bytes are not in the allow-list. The
|
||||||
|
// theme flash guard is the ONLY inline script in the app; its hash is
|
||||||
|
// pinned (and drift-checked) by middleware's
|
||||||
|
// TestThemeFlashGuardHashMatchesCSP. A second inline script renders
|
||||||
|
// fine and simply never executes.
|
||||||
|
// 2. Native on*= event-handler attributes (onclick=, onchange=, ...).
|
||||||
|
// Inline handlers never go through the hash path at all — they are
|
||||||
|
// always refused under hash-mode CSP. Alpine @click/@change component
|
||||||
|
// methods are the replacement (see the 2026-10 conversion commits).
|
||||||
|
//
|
||||||
|
// Neither failure mode is observable in Go tests or at build time, so this
|
||||||
|
// source scan IS the tripwire: it fails the build if either pattern appears
|
||||||
|
// in a templ source file. htmx's eval-dependent bindings (hx-on*, js:-
|
||||||
|
// prefixed hx-vals) are included — 'unsafe-eval' is not granted either.
|
||||||
|
|
||||||
|
var cspHandlerAttrRe = regexp.MustCompile(
|
||||||
|
`on(click|error|submit|load|change|input|focus|blur|mouseover|keydown|keyup)\s*=\s*("|\{)`)
|
||||||
|
|
||||||
|
var cspInlineScriptRe = regexp.MustCompile(`(?s)<script\b([^>]*)>`)
|
||||||
|
|
||||||
|
var cspEvalBindingRe = regexp.MustCompile(
|
||||||
|
`\b(hx-on[a-z:-]*|data-hx-on[a-z:-]*)\s*=|(hx-vals|data-hx-vals)\s*=\s*["'][^"']*\bjs:`)
|
||||||
|
|
||||||
|
// cspSanctionedInlineScripts lists the templ files allowed to carry inline
|
||||||
|
// <script> content. The theme flash guard's bytes are pinned by the CSP
|
||||||
|
// hash + drift test in internal/middleware.
|
||||||
|
var cspSanctionedInlineScripts = map[string]bool{
|
||||||
|
"theme_flash_guard.templ": true,
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTemplSourcesAreCSPSafe(t *testing.T) {
|
||||||
|
files, err := filepath.Glob("*.templ")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(files) == 0 {
|
||||||
|
t.Fatal("no .templ files found — this test must run from the templates package directory")
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, f := range files {
|
||||||
|
raw, err := os.ReadFile(f)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
src := string(raw)
|
||||||
|
|
||||||
|
for _, m := range cspHandlerAttrRe.FindAllStringIndex(src, -1) {
|
||||||
|
t.Errorf("%s:%d: native on*= handler attribute (%s) — CSP hash-mode refuses it; use an Alpine @binding with a component method",
|
||||||
|
f, 1+strings.Count(src[:m[0]], "\n"), src[m[0]:m[1]])
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, m := range cspInlineScriptRe.FindAllStringSubmatchIndex(src, -1) {
|
||||||
|
tag := src[m[0]:m[1]]
|
||||||
|
if strings.Contains(tag, "src=") {
|
||||||
|
continue // external script, governed by script-src 'self'
|
||||||
|
}
|
||||||
|
if cspSanctionedInlineScripts[filepath.Base(f)] {
|
||||||
|
continue // the theme flash guard: hash-pinned in middleware
|
||||||
|
}
|
||||||
|
t.Errorf("%s:%d: inline <script> without src= (%s) — only the theme flash guard may be inline; add its hash to middleware.ScriptHashThemeFlashGuard + the drift test, or move the code to a bundled module",
|
||||||
|
f, 1+strings.Count(src[:m[0]], "\n"), tag)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, m := range cspEvalBindingRe.FindAllStringIndex(src, -1) {
|
||||||
|
t.Errorf("%s:%d: htmx eval-dependent binding (%s) — 'unsafe-eval' is not granted; use hx-trigger/hx-post forms that need no eval",
|
||||||
|
f, 1+strings.Count(src[:m[0]], "\n"), src[m[0]:m[1]])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -108,7 +108,7 @@ templ UnlinkedBooks(user User, unlinkedBooks []UnlinkedBookData) {
|
|||||||
<div class="card p-6">
|
<div class="card p-6">
|
||||||
<div class="flex gap-6">
|
<div class="flex gap-6">
|
||||||
<div class="flex items-start pt-2">
|
<div class="flex items-start pt-2">
|
||||||
<input type="checkbox" class="unlinked-checkbox w-5 h-5" data-progress-id={ book.ProgressID } data-title={ book.TitleFromDevice } onchange="updateSelectedCount()"/>
|
<input type="checkbox" class="unlinked-checkbox w-5 h-5" data-progress-id={ book.ProgressID } data-title={ book.TitleFromDevice }/>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex-1 min-w-0">
|
<div class="flex-1 min-w-0">
|
||||||
<div class="flex items-center gap-3 mb-4">
|
<div class="flex items-center gap-3 mb-4">
|
||||||
|
|||||||
@@ -308,7 +308,7 @@ func UnlinkedBooks(user User, unlinkedBooks []UnlinkedBookData) templ.Component
|
|||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "\" onchange=\"updateSelectedCount()\"></div><div class=\"flex-1 min-w-0\"><div class=\"flex items-center gap-3 mb-4\"><span class=\"grid place-items-center h-11 w-11 rounded-xl shrink-0\" style=\"background-color: var(--accent-muted); color: var(--accent);\">")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "\"></div><div class=\"flex-1 min-w-0\"><div class=\"flex items-center gap-3 mb-4\"><span class=\"grid place-items-center h-11 w-11 rounded-xl shrink-0\" style=\"background-color: var(--accent-muted); color: var(--accent);\">")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in new issue
Block a user