Skip to content
quarry Artifact [14dd5a9ab6]
Login

Artifact [14dd5a9ab6]

Artifact 14dd5a9ab6f8e006e33d615e1eae03eb5d0bc290b2a7df670b52d6acdfa220bc:


// 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 &#43; 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
}