Artifact 249e131f9c097b7c43b7021e450e60e0d4f577a17727c8fd3ba17a4cbb078500:
- File
internal/web/csp.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: 3840) [annotate] [blame] [check-ins using]
// csp.go carries the Content-Security-Policy and the per-response nonce // that makes a strict one possible. // // quarry served no page policy at all before this. The blocker for a // strict one was never the data-on:* attributes — those are data // attributes, and script-src does not govern them — it was unsafe-eval, // because datastar compiled expressions with the Function constructor. // 1.0.3 takes a nonce instead, so the policy below needs neither // unsafe-eval nor unsafe-inline. // // What the policy does NOT do is make expressions safe with untrusted // content: it authorises scripts, it does not sanitize them. quarry // renders commit messages, ticket titles and forum posts from mirrored // upstreams, so the rule that actually protects those surfaces is // architectural — user values travel through signals, expressions stay // static — and it is pinned by TestNoUntrustedInterpolationInExpressions. package web import ( "context" "crypto/rand" "encoding/base64" "net/http" ) type ctxNonce struct{} // policy is the Content-Security-Policy every page carries, with %s // standing in for the response's nonce. // // - default-src 'self': everything comes from this origin. quarry // embeds its own assets and calls nobody. // - script-src: the nonce alone. No unsafe-eval (datastar CSP mode), // no unsafe-inline (no inline handlers remain). // - style-src 'self': the stylesheet is a served asset now, so it // needs no unsafe-inline either. The few style="" attributes on // elements are governed by style-src-attr, granted narrowly below — // anchor positioning writes anchor-name per element. // - img-src includes data: for the identicons, which are generated // inline rather than fetched. // - connect-src 'self': the SSE streams and fragment fetches. // - frame-ancestors 'none' and form-action 'self': clickjacking and // form-retargeting, neither of which quarry needs. const policy = "default-src 'self'; " + "script-src 'self' 'nonce-%s'; " + "style-src 'self'; style-src-attr 'unsafe-inline'; " + "img-src 'self' data:; " + "font-src 'self'; " + "connect-src 'self'; " + "base-uri 'none'; " + "form-action 'self'; " + "frame-ancestors 'none'" // newNonce is 16 random bytes, base64. Generated per full-page // response: a nonce reused across responses is one an injected tag can // learn and replay. func newNonce() string { var b [16]byte if _, err := rand.Read(b[:]); err != nil { // A failure here must not serve a page with a guessable nonce, // and must not serve a page with none — either would quietly // disable the policy. Callers treat "" as "send no policy", // which fails closed to today's behaviour rather than to an // authorised-anything one. return "" } return base64.RawStdEncoding.EncodeToString(b[:]) } // WithCSP mints a nonce, puts it where the page templates can read it, // and sets the policy header. // // Applied to page responses only. An API JSON body, an SSE stream and a // tarball have no scripts to authorise, and a header on them is noise // in every response for no gain. func WithCSP(h http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { nonce := newNonce() if nonce == "" { h.ServeHTTP(w, r) return } w.Header().Set("Content-Security-Policy", cspHeader(nonce)) h.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), ctxNonce{}, nonce))) }) } func cspHeader(nonce string) string { // fmt.Sprintf would do; this avoids the import for one substitution. const mark = "%s" i := indexOf(policy, mark) if i < 0 { return policy } return policy[:i] + nonce + policy[i+len(mark):] } func indexOf(h, n string) int { for i := 0; i+len(n) <= len(h); i++ { if h[i:i+len(n)] == n { return i } } return -1 }