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 +
// 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: 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")
}