Skip to content
quarry Artifact [e6dca84588]
Login

Artifact [e6dca84588]

Artifact e6dca8458885a0b34e6e0896b5e77fd386149d40916c6fb0bb91bb5551073a21:


package web

import (
	"html/template"
	"net/http"
	"net/http/httptest"
	"strings"
	"testing"
)

// Datastar expressions are not sanitized by CSP mode — the docs are
// blunt that a nonce authorises a script, it does not clean it. What
// actually protects these attributes is html/template's contextual
// escaping: it strips a "data-" prefix before classifying an attribute,
// so data-on:drop reads as an on* handler and gets the JAVASCRIPT
// escaper rather than the HTML one.
//
// The distinction matters and is easy to lose. HTML-escaping a quote
// produces ', which the parser decodes back to ' before any script
// reads the attribute — so an HTML-escaped value would still break out
// of a JS string. JS-escaping produces ', which does not.
//
// This is pinned because the values reaching these expressions include
// a board column's status, which comes from .quarry/tickets.json — and
// for a MIRRORED repository that file is upstream content quarry does
// not control.
func TestExpressionsGetJavaScriptEscaping(t *testing.T) {
	tmpl := template.Must(template.New("x").Parse(
		`<div data-on:drop__prevent="@post('/r/repo/ticket/move?to={{.Status}}')"></div>`))
	var b strings.Builder
	hostile := struct{ Status string }{Status: `x') + alert(document.cookie) + ('`}
	if err := tmpl.Execute(&b, hostile); err != nil {
		t.Fatal(err)
	}
	out := b.String()
	if strings.Contains(out, "') + alert(") {
		t.Fatalf("a hostile status broke out of the expression:\n%s", out)
	}
	if !strings.Contains(out, `'`) {
		t.Errorf("the quote was not JS-escaped; HTML escaping decodes back to a quote:\n%s", out)
	}
}

// The policy needs neither of the two escapes that make CSP decorative.
func TestPolicyHasNoUnsafeEscapes(t *testing.T) {
	h := cspHeader("TESTNONCE", nil)
	for _, forbidden := range []string{"unsafe-eval", "unsafe-inline'; script-src", "'unsafe-inline' 'nonce"} {
		if strings.Contains(h, forbidden) {
			t.Errorf("policy contains %q: %s", forbidden, h)
		}
	}
	if !strings.Contains(h, "script-src 'self' 'nonce-TESTNONCE'") {
		t.Errorf("script-src does not carry the nonce: %s", h)
	}
	for _, want := range []string{"default-src 'self'", "frame-ancestors 'none'", "base-uri 'self'"} {
		if !strings.Contains(h, want) {
			t.Errorf("policy is missing %q", want)
		}
	}
}

// A nonce that repeats is one an injected tag can replay.
func TestNonceIsFreshPerResponse(t *testing.T) {
	seen := map[string]bool{}
	h := WithCSP(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {}))
	for i := 0; i < 8; i++ {
		rec := httptest.NewRecorder()
		h.ServeHTTP(rec, httptest.NewRequest("GET", "/", nil))
		got := rec.Header().Get("Content-Security-Policy")
		if got == "" {
			t.Fatal("no policy sent")
		}
		if seen[got] {
			t.Fatal("a nonce repeated across responses")
		}
		seen[got] = true
	}
}

// No inline handler may come back: they are the one thing a strict
// policy cannot allow, and adding one is a single attribute.
func TestNoInlineEventHandlers(t *testing.T) {
	for _, attr := range []string{
		` onclick="`, ` onchange="`, ` onfocus="`, ` onsubmit="`,
		` oninput="`, ` onload="`, ` onerror="`,
	} {
		if strings.Contains(baseHTML, attr) {
			t.Errorf("an inline handler is back: %s", strings.TrimSpace(attr))
		}
	}
}

// The nonce must survive an HTML attribute unchanged.
//
// Standard base64 yields '+', which html/template escapes to &#43;
// inside data-nonce. The browser decodes it before datastar reads it,
// but the two values then disagree by one round of encoding and every
// script datastar injects is rejected — SILENTLY, with no console
// violation. The page simply stops being interactive: sentinels never
// fire, selects never save, and nothing says why.
func TestNonceNeedsNoHTMLEscaping(t *testing.T) {
	for i := 0; i < 200; i++ {
		n := newNonce()
		if n == "" {
			t.Fatal("empty nonce")
		}
		var b strings.Builder
		template.Must(template.New("x").Parse(`<html data-nonce="{{.}}">`)).Execute(&b, n)
		if !strings.Contains(b.String(), `data-nonce="`+n+`"`) {
			t.Fatalf("nonce %q was altered by attribute escaping: %s", n, b.String())
		}
	}
}

// A page under a repository with a bucket bound may show and send
// bytes to the bucket: its endpoint and public base join img-src,
// media-src and connect-src, and nothing else. A repository with no
// binding and a page not under one carry the plain policy, and a page
// that answers 404 does too — the header must not say where a
// repository the reader may not see keeps its files.
func TestPolicyWidensToTheBucketForRepositoryPages(t *testing.T) {
	asked := map[string]int{}
	origins := func(repo string) []string {
		asked[repo]++
		if repo == "bound" {
			return []string{"https://acct.r2.cloudflarestorage.com", "https://files.example.org"}
		}
		return nil
	}
	status := http.StatusOK
	h := WithCSP(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
		w.WriteHeader(status)
		w.Write([]byte("<html>"))
	}), WithRepoOrigins(origins))
	get := func(path string) string {
		rec := httptest.NewRecorder()
		h.ServeHTTP(rec, httptest.NewRequest("GET", path, nil))
		return rec.Header().Get("Content-Security-Policy")
	}
	bound := get("/r/bound/ticket/abc")
	for _, want := range []string{
		"img-src 'self' data: blob: https://acct.r2.cloudflarestorage.com https://files.example.org;",
		"media-src 'self' https://acct.r2.cloudflarestorage.com https://files.example.org;",
		"connect-src 'self' https://acct.r2.cloudflarestorage.com https://files.example.org;",
		"script-src 'self' 'nonce-",
	} {
		if !strings.Contains(bound, want) {
			t.Errorf("FAIL: bound page lacks %q: %s", want, bound)
		}
	}
	if strings.Contains(bound, "script-src 'self' 'nonce-") && strings.Count(bound, "cloudflarestorage") != 3 {
		t.Errorf("FAIL: the bucket origin reached a directive other than the three: %s", bound)
	}
	for name, path := range map[string]string{"unbound repo": "/r/plain/wiki/Home", "non-repo page": "/fleet"} {
		if got := get(path); strings.Contains(got, "cloudflarestorage") || !strings.Contains(got, "media-src 'self';") {
			t.Errorf("FAIL: %s carries a bucket origin or lost media-src: %s", name, got)
		}
	}
	status = http.StatusNotFound
	if got := get("/r/bound/ticket/abc"); strings.Contains(got, "cloudflarestorage") || got == "" {
		t.Errorf("FAIL: a 404 under a bound repository names the bucket: %s", got)
	}
	// Two requests, one lookup: the second was answered from the cache.
	if asked["bound"] != 1 || asked["fleet"] != 0 {
		t.Errorf("FAIL: lookups %v, want bound once and never a non-repo page", asked)
	}
	t.Log("ok: bucket origins widen img/media/connect on a readable page under a bound repository only")
}

// The origin is scheme and host, whatever else the steward typed.
func TestOriginOfDropsThePath(t *testing.T) {
	for in, want := range map[string]string{
		"https://files.example.org/some/prefix": "https://files.example.org",
		"http://127.0.0.1:9000":                 "http://127.0.0.1:9000",
		"files.example.org":                     "",
		"":                                      "",
	} {
		if got := OriginOf(in); got != want {
			t.Errorf("FAIL: OriginOf(%q) = %q, want %q", in, got, want)
		}
	}
	t.Log("ok: origins are scheme+host")
}