Skip to content
quarry Artifact [66fc273002]
Login

Artifact [66fc273002]

Artifact 66fc273002128335ea3f947d6e34f9363e6648ee0ad9d131ea5e1512b0b0334f:


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))
		}
	}
}