Skip to content
quarry Artifact [c172d9af0f]
Login

Artifact [c172d9af0f]

Artifact c172d9af0feb812d3aa7566ac4b732def7ad7dbe4a44301f6785bdce04ddd341:


// 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 ""
	}
	// 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
}