// 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'; " +
// 'self', not 'none'. Every page states its own address in a
// <base href> — because a page opened as http://user:pw@host/
// keeps those credentials in document.baseURI, and fetch() refuses
// any URL carrying them, which silently broke every enhancement on
// the site at once. 'none' blocked the tag that fixes it, so the
// fix had never once applied and the browser logged a violation on
// every page load instead.
//
// 'self' still forbids the attack this directive exists for: an
// injected <base href="https://elsewhere/"> that quietly repoints
// every relative URL on the page. A base on our own origin can only
// aim at us.
"base-uri 'self'; " +
"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 ""
}
// URL-safe alphabet: standard base64 yields '+' and '/', and the '+'
// is escaped to + inside the data-nonce attribute. The browser
// decodes that before datastar reads it, but it is one needless
// round of encoding between two places that must agree exactly.
return base64.RawURLEncoding.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
}