package cabana import ( "context" "encoding/json" "errors" "html/template" "net/http" "net/http/httptest" "os" "reflect" "strings" "testing" "git.golem15.com/golem15/summercms/modules/bouncer" "git.golem15.com/golem15/summercms/modules/towel" "golang.org/x/net/html" "golang.org/x/net/html/atom" ) // sanitizeHTML runs raw markup through the same fragment parse and allowlist // walk a rendered partial goes through, without html/template in front (which // would already strip comments). func sanitizeHTML(t *testing.T, src string) []PartialNode { t.Helper() container := &html.Node{Type: html.ElementNode, Data: "div", DataAtom: atom.Div} parsed, err := html.ParseFragment(strings.NewReader(src), container) if err != nil { t.Fatalf("parse %q: %v", src, err) } nodes, err := sanitizePartialNodes(parsed, 0, &partialBudget{nodes: partialMaxNodes}) if err != nil { t.Fatalf("sanitize %q: %v", src, err) } return nodes } func nodesJSON(t *testing.T, nodes []PartialNode) string { t.Helper() raw, err := json.Marshal(nodes) if err != nil { t.Fatal(err) } return string(raw) } // renderPartial parses src as a partial template and renders it once. func renderPartial(t *testing.T, src string, data any) ([]PartialNode, error) { t.Helper() compiled, err := parsePartial("probe", []byte(src)) if err != nil { t.Fatalf("parse template: %v", err) } return compiled.render(context.Background(), nil, data) } // TestPhase101PartialSanitizer covers the server half of T-10.1-08, T-10.1-09 // and T-10.1-12: the tag, attribute and URL allowlist, escaping of view-model // data, per-request translation, the size, node and depth caps, and the // view-model guard. func TestPhase101PartialSanitizer(t *testing.T) { t.Run("dropped tags go with their subtree", func(t *testing.T) { for tag := range partialDroppedTags { var src string switch tag { case "input", "link", "meta", "base", "embed": src = `
keep
<` + tag + ` value="gone" href="/gone" content="gone">tail
` case "svg", "math": src = `keep
<` + tag + `>tail
` case "select": src = `keep
tail
` default: src = `keep
<` + tag + `>gone gone` + tag + `>tail
` } got := nodesJSON(t, sanitizeHTML(t, src)) if strings.Contains(got, "gone") || strings.Contains(got, `"`+tag+`"`) { t.Fatalf("%s survived: %s", tag, got) } if !strings.Contains(got, `"text":"keep"`) || !strings.Contains(got, `"text":"tail"`) { t.Fatalf("%s took its siblings with it: %s", tag, got) } } if len(partialDroppedTags) != 19 { t.Fatalf("dropped tag list has %d entries, the test expects 19", len(partialDroppedTags)) } }) t.Run("unknown elements are unwrapped, children kept", func(t *testing.T) { nodes := sanitizeHTML(t, `| c |
`, "src", false},
} {
nodes := sanitizeHTML(t, tc.src)
if len(nodes) != 1 {
t.Fatalf("%s: nodes = %s", tc.src, nodesJSON(t, nodes))
}
_, kept := nodes[0].Attrs[tc.attr]
if kept != tc.keep {
t.Fatalf("%s: %s kept=%v, want %v (%v)", tc.src, tc.attr, kept, tc.keep, nodes[0].Attrs)
}
}
if got := sanitizeHTML(t, `ab
`)) if strings.Contains(got, "secret") || strings.Contains(got, "also") || strings.Contains(got, "html") { t.Fatalf("comment leaked: %s", got) } nodes, err := renderPartial(t, "ab
", nil) if err != nil || strings.Contains(nodesJSON(t, nodes), "template comment") { t.Fatalf("rendered comment: %v %s", err, nodesJSON(t, nodes)) } }) t.Run("view-model markup stays text", func(t *testing.T) { hostile := `bold` nodes, err := renderPartial(t, `{{ .Data.Name }}
t`, struct{ Name string }{hostile}) if err != nil { t.Fatal(err) } want := []PartialNode{ {Tag: "p", Attrs: map[string]string{"class": "n"}, Children: []PartialNode{{Text: hostile}}}, {Tag: "span", Attrs: map[string]string{"title": hostile}, Children: []PartialNode{{Text: "t"}}}, } if !reflect.DeepEqual(nodes, want) { t.Fatalf("nodes = %s", nodesJSON(t, nodes)) } }) t.Run("trans resolves per request on one compiled partial", func(t *testing.T) { _, tr := extTranslator(t) _, cc := mustCompileExt(t, newExtController(), os.DirFS(extDir)) stats := cc.partials["stats"] vm, _ := extPartialView("stats", nil) for _, tc := range []struct{ locale, label string }{{"en", "All gadgets"}, {"pl", "Wszystkie gadżety"}, {"en", "All gadgets"}} { nodes, err := stats.render(towel.WithLocale(context.Background(), tc.locale), tr, vm) if err != nil { t.Fatalf("%s render: %v", tc.locale, err) } got := nodesJSON(t, nodes) if !strings.Contains(got, `"text":"`+tc.label+`"`) || !strings.Contains(got, `"text":"3"`) || !strings.Contains(got, `"class":"summer-stats"`) { t.Fatalf("%s nodes = %s", tc.locale, got) } } // The pristine template was never executed, so it can still be cloned. if _, err := stats.pristine.Clone(); err != nil { t.Fatalf("pristine template was executed: %v", err) } }) t.Run("caps", func(t *testing.T) { big := strings.Repeat("x", partialMaxBytes+1) if _, err := renderPartial(t, `{{ .Data }}
`, big); !errors.Is(err, errPartialTooLarge) { t.Fatalf("size cap err = %v", err) } if _, err := renderPartial(t, `{{ .Data }}
`, strings.Repeat("x", partialMaxBytes-len(""))); err != nil { t.Fatalf("output at the size cap: %v", err) } // Every x is two nodes: the element and its text. atCap := strings.Repeat("x", partialMaxNodes/2) if _, err := renderPartial(t, atCap, nil); err != nil { t.Fatalf("%d nodes: %v", partialMaxNodes, err) } if _, err := renderPartial(t, atCap+"y", nil); err == nil || !strings.Contains(err.Error(), "2000 nodes") { t.Fatalf("node cap err = %v", err) } nested := func(depth int) string { return strings.Repeat("