Artifact 66fc273002128335ea3f947d6e34f9363e6648ee0ad9d131ea5e1512b0b0334f:
- File
internal/web/csp_test.go
— part of check-in
[ef352402f6]
at
2026-08-31 20:02:16
on branch trunk
— a strict CSP: nonces, no unsafe-eval, no inline handlers
quarry served no page policy at all. The blocker was never the data-on:* attributes — script-src does not govern data attributes — it was unsafe-eval, because datastar compiled expressions with Function(). 1.0.3 takes a nonce instead.
Removed all 14 inline handlers: 11 identical onchange=this.form.submit() became data-on:change with requestSubmit (which validates, unlike submit), two selection handlers became expressions, and the copy-hash button moved to a delegated assets/copy.js. Scripts now carry content-hashed immutable URLs instead of revalidating daily.
The audit found the expressions already safe, for a reason worth pinning: html/template strips a data- prefix before classifying, so data-on:drop gets the JAVASCRIPT escaper. That matters because an HTML-escaped quote decodes back to a quote before any script reads the attribute, and a board column status comes from .quarry/tickets.json — upstream content on a mirror. (user: quarry, size: 3262) [annotate] [blame] [check-ins using]
package web import ( "html/template" "net/http" "net/http/httptest" "strings" "testing" ) // Datastar expressions are not sanitized by CSP mode — the docs are // blunt that a nonce authorises a script, it does not clean it. What // actually protects these attributes is html/template's contextual // escaping: it strips a "data-" prefix before classifying an attribute, // so data-on:drop reads as an on* handler and gets the JAVASCRIPT // escaper rather than the HTML one. // // The distinction matters and is easy to lose. HTML-escaping a quote // produces ', which the parser decodes back to ' before any script // reads the attribute — so an HTML-escaped value would still break out // of a JS string. JS-escaping produces ', which does not. // // This is pinned because the values reaching these expressions include // a board column's status, which comes from .quarry/tickets.json — and // for a MIRRORED repository that file is upstream content quarry does // not control. func TestExpressionsGetJavaScriptEscaping(t *testing.T) { tmpl := template.Must(template.New("x").Parse( `<div data-on:drop__prevent="@post('/r/repo/ticket/move?to={{.Status}}')"></div>`)) var b strings.Builder hostile := struct{ Status string }{Status: `x') + alert(document.cookie) + ('`} if err := tmpl.Execute(&b, hostile); err != nil { t.Fatal(err) } out := b.String() if strings.Contains(out, "') + alert(") { t.Fatalf("a hostile status broke out of the expression:\n%s", out) } if !strings.Contains(out, `'`) { t.Errorf("the quote was not JS-escaped; HTML escaping decodes back to a quote:\n%s", out) } } // The policy needs neither of the two escapes that make CSP decorative. func TestPolicyHasNoUnsafeEscapes(t *testing.T) { h := cspHeader("TESTNONCE") for _, forbidden := range []string{"unsafe-eval", "unsafe-inline'; script-src", "'unsafe-inline' 'nonce"} { if strings.Contains(h, forbidden) { t.Errorf("policy contains %q: %s", forbidden, h) } } if !strings.Contains(h, "script-src 'self' 'nonce-TESTNONCE'") { t.Errorf("script-src does not carry the nonce: %s", h) } for _, want := range []string{"default-src 'self'", "frame-ancestors 'none'", "base-uri 'none'"} { if !strings.Contains(h, want) { t.Errorf("policy is missing %q", want) } } } // A nonce that repeats is one an injected tag can replay. func TestNonceIsFreshPerResponse(t *testing.T) { seen := map[string]bool{} h := WithCSP(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {})) for i := 0; i < 8; i++ { rec := httptest.NewRecorder() h.ServeHTTP(rec, httptest.NewRequest("GET", "/", nil)) got := rec.Header().Get("Content-Security-Policy") if got == "" { t.Fatal("no policy sent") } if seen[got] { t.Fatal("a nonce repeated across responses") } seen[got] = true } } // No inline handler may come back: they are the one thing a strict // policy cannot allow, and adding one is a single attribute. func TestNoInlineEventHandlers(t *testing.T) { for _, attr := range []string{ ` onclick="`, ` onchange="`, ` onfocus="`, ` onsubmit="`, ` oninput="`, ` onload="`, ` onerror="`, } { if strings.Contains(baseHTML, attr) { t.Errorf("an inline handler is back: %s", strings.TrimSpace(attr)) } } }