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")
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 'none'"} {
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())
}
}
}