Skip to content
quarry Artifact [c65b9bc8aa]
Login

Artifact [c65b9bc8aa]

Artifact c65b9bc8aa1b115684483ddbb090fec0bf11c89ebf5cd1f1761088f46e4b816b:


// 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, and blob: for the preview of a file
//     pasted into a composer before it is uploaded anywhere.
//     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: blob:; " +
	"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 &#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[:])
}

// 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
	place   func(name string) (ns string, ok bool)
}

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

// WithHandlerNamespaces has the policy read a path's repository the way
// a handler built with opts does: /r/{owner}/{repo} names the second
// segment when opts carry WithNamespaces. Passed rather than found on
// the wrapped handler, because any middleware between the two would
// hide it and owner paths would silently lose their bucket.
func WithHandlerNamespaces(opts ...Option) CSPOption {
	var ho handlerOpts
	for _, f := range opts {
		f(&ho)
	}
	return func(o *cspOpts) { o.place = ho.place }
}

// 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, o.place); 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: blob:", "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
}