Files
bookhoard/templates/csp_source_test.go
T
John O'Keefe 4681e98766 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).
2026-10-26 08:17:49 -04:00

86 lines
3.2 KiB
Go

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]])
}
}
}