// 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"
"net/url"
"strings"
"sync"
"time"
)
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.
// - media-src 'self': stated on its own rather than left to
// default-src, because it is one of the three directives a page
// under a repository with a bucket bound widens (see widen).
// - 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:; " +
"media-src 'self'; " +
"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[:])
}
// CSPOption widens WithCSP. Kept apart from Option because the policy
// wraps the whole page tree from outside HandlerWith, and the two
// option sets would otherwise share a type but never a consumer.
type CSPOption func(*cspOpts)
type cspOpts struct {
origins func(repo string) []string
}
// originTTL bounds how long a repository's bucket origins are believed.
// Long enough that a busy page never asks the OLTP per request, short
// enough that a rebinding shows up before the steward has finished
// reading the settings page they saved it from.
const originTTL = 30 * time.Second
// WithRepoOrigins names the origins a page under /r/{repo}/ may load
// media from and upload to: the repository's bucket endpoint and its
// public base (RFC 0016). Every stub attachment is served by a
// redirect to one of them, and CSP follows redirects, so without this
// the browser refuses the <img> in the card, the <video> preview and
// the scripted PUT alike.
//
// fn answers scheme+host origins, or nothing for a repository with no
// binding; its answers are cached per repository for originTTL.
func WithRepoOrigins(fn func(repo string) []string) CSPOption {
c := &originCache{fn: fn, seen: map[string]originEntry{}}
return func(o *cspOpts) { o.origins = c.get }
}
type originEntry struct {
origins []string
until time.Time
}
type originCache struct {
fn func(string) []string
mu sync.Mutex
seen map[string]originEntry
}
func (c *originCache) get(repo string) []string {
c.mu.Lock()
e, ok := c.seen[repo]
c.mu.Unlock()
if ok && time.Now().Before(e.until) {
return e.origins
}
origins := c.fn(repo)
c.mu.Lock()
c.seen[repo] = originEntry{origins: origins, until: time.Now().Add(originTTL)}
c.mu.Unlock()
return origins
}
// 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.
//
// The header is set before the handler runs, then REPLACED with the
// widened one at WriteHeader when the page is under a repository with
// a bucket and the answer is not an error. The delay is not about
// cost: a page that answers 404 to a reader who may not see the
// repository must not carry the repository's bucket host in a header,
// which would say both that the repository exists and where its files
// are. The handler's own status is the one signal that the reader was
// allowed in.
func WithCSP(h http.Handler, opts ...CSPOption) http.Handler {
var o cspOpts
for _, opt := range opts {
opt(&o)
}
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, nil))
ctx := context.WithValue(r.Context(), ctxNonce{}, nonce)
if repo := repoOfPath(r.URL.Path); repo != "" && o.origins != nil {
w = &cspWriter{ResponseWriter: w, nonce: nonce, repo: repo, origins: o.origins}
}
h.ServeHTTP(w, r.WithContext(ctx))
})
}
// cspWriter widens the policy once the handler has decided the page
// may be seen. Flush is forwarded because the page tree's streams
// type-assert for it, and Unwrap keeps http.ResponseController
// working through the wrapper.
type cspWriter struct {
http.ResponseWriter
nonce, repo string
origins func(string) []string
wrote bool
}
func (c *cspWriter) WriteHeader(status int) {
if !c.wrote {
c.wrote = true
if status < 400 {
if extra := c.origins(c.repo); len(extra) > 0 {
c.Header().Set("Content-Security-Policy", cspHeader(c.nonce, extra))
}
}
}
c.ResponseWriter.WriteHeader(status)
}
func (c *cspWriter) Write(b []byte) (int, error) {
if !c.wrote {
c.WriteHeader(http.StatusOK)
}
return c.ResponseWriter.Write(b)
}
func (c *cspWriter) Flush() {
if !c.wrote {
c.WriteHeader(http.StatusOK)
}
if f, ok := c.ResponseWriter.(http.Flusher); ok {
f.Flush()
}
}
func (c *cspWriter) Unwrap() http.ResponseWriter { return c.ResponseWriter }
// cspHeader is the policy with the nonce in place and, when extra
// origins are given, img-src, media-src and connect-src widened to
// them (see widenPolicy). Only those three: a bucket serves bytes the page shows and
// takes bytes the page sends, and nothing it hosts may run as script
// or style here.
func cspHeader(nonce string, extra []string) string {
h := strings.Replace(policy, "%s", nonce, 1)
if len(extra) == 0 {
return h
}
return widenPolicy(h, extra)
}
// widenPolicy appends origins to the three directives, each once. The
// policy is a fixed string, so replacing the directive prefix is
// exact rather than a parse.
func widenPolicy(h string, extra []string) string {
suffix := " " + strings.Join(extra, " ")
for _, d := range []string{"img-src 'self' data:", "media-src 'self'", "connect-src 'self'"} {
h = strings.Replace(h, d+";", d+suffix+";", 1)
}
return h
}
// OriginOf is the scheme+host of a URL as CSP wants it, or "" when
// the URL has no host. A public base of https://files.example.org/x
// grants files.example.org, not the path: CSP source lists match on
// origin and a path would only narrow one directive by accident.
func OriginOf(raw string) string {
u, err := url.Parse(raw)
if err != nil || u.Host == "" || u.Scheme == "" {
return ""
}
return u.Scheme + "://" + u.Host
}