// SPDX-License-Identifier: Apache-2.0 package sando import ( "bytes" "errors" "fmt" "html/template" "strings" "testing" "unicode/utf8" ) func TestHTMLTemplateDifferentialCorpus(t *testing.T) { t.Parallel() tests := []struct { name string source string prefix string suffix string write func(*bytes.Buffer, any) error values []string }{ { name: "HTML text", source: `
{{.}}
`, prefix: ``, suffix: `
`, write: func(output *bytes.Buffer, value any) error { return WriteText(output, value) }, values: differentialTextValues(), }, { name: "quoted attribute", source: `x
`, prefix: `x
`, write: func(output *bytes.Buffer, value any) error { return WriteAttr(output, value) }, values: differentialTextValues(), }, { name: "RCDATA", source: ``, prefix: ``, write: func(output *bytes.Buffer, value any) error { return WriteRCDATA(output, value) }, values: differentialTextValues(), }, { name: "safe URL", source: `x`, prefix: `x`, write: func(output *bytes.Buffer, value any) error { return WriteURL(output, value) }, values: []string{"", "/", "./relative", "?q=a&next=b", "#section", "https://example.test/a?x=1&y=2", "HTTP://example.test/", "mailto:reader@example.test"}, }, } for _, test := range tests { test := test t.Run(test.name, func(t *testing.T) { t.Parallel() parsed := template.Must(template.New(test.name).Parse(test.source)) for _, value := range test.values { var baseline bytes.Buffer if err := parsed.Execute(&baseline, value); err != nil { t.Fatalf("html/template value %q: %v", value, err) } var output bytes.Buffer output.WriteString(test.prefix) if err := test.write(&output, value); err != nil { t.Fatalf("Sandwich Hime value %q: %v", value, err) } output.WriteString(test.suffix) if output.String() != baseline.String() { t.Fatalf("differential mismatch for %q\nhtml/template: %q\nSandwich Hime: %q", value, baseline.String(), output.String()) } } }) } } func TestHTMLTemplateDifferentialUnsafeURLPolicy(t *testing.T) { t.Parallel() parsed := template.Must(template.New("url").Parse(`x`)) values := []string{ "javascript:alert(1)", " JAVASCRIPT:alert(1) ", "data:text/html,", "vbscript:msgbox(1)", "unknown:opaque", "java%73cript:alert(1)", } for _, value := range values { var baseline bytes.Buffer if err := parsed.Execute(&baseline, value); err != nil { t.Fatalf("html/template value %q: %v", value, err) } if !strings.Contains(baseline.String(), "#ZgotmplZ") { t.Fatalf("html/template did not block corpus URL %q: %q", value, baseline.String()) } var output bytes.Buffer err := WriteURL(&output, value) if !errors.Is(err, ErrUnsafeURL) { t.Fatalf("Sandwich Hime accepted corpus URL %q: output=%q err=%v", value, output.String(), err) } if output.Len() != 0 { t.Fatalf("Sandwich Hime wrote bytes before rejecting %q: %q", value, output.String()) } } } func TestHTMLTemplateDifferentialDocumentedStrictness(t *testing.T) { t.Parallel() t.Run("invalid UTF-8", func(t *testing.T) { value := "invalid UTF-8: \xff:end" parsed := template.Must(template.New("text").Parse(`{{.}}
`)) var baseline bytes.Buffer if err := parsed.Execute(&baseline, value); err != nil { t.Fatal(err) } if utf8.Valid(baseline.Bytes()) { t.Fatalf("baseline unexpectedly normalized invalid UTF-8: %q", baseline.Bytes()) } var output bytes.Buffer if err := WriteText(&output, value); err != nil { t.Fatal(err) } if !utf8.Valid(output.Bytes()) || !strings.Contains(output.String(), "\uFFFD") { t.Fatalf("Sandwich Hime did not normalize invalid UTF-8: %q", output.Bytes()) } }) t.Run("control in otherwise allowed URL", func(t *testing.T) { value := "https:\n//example.test/" parsed := template.Must(template.New("url").Parse(`x`)) var baseline bytes.Buffer if err := parsed.Execute(&baseline, value); err != nil { t.Fatal(err) } if !strings.Contains(strings.ToLower(baseline.String()), "%0a") { t.Fatalf("baseline did not visibly encode the control: %q", baseline.String()) } var output bytes.Buffer if err := WriteURL(&output, value); !errors.Is(err, ErrUnsafeURL) { t.Fatalf("Sandwich Hime did not fail closed: output=%q err=%v", output.String(), err) } if output.Len() != 0 { t.Fatalf("Sandwich Hime wrote before rejecting the control: %q", output.String()) } }) t.Run("explicit tel allowlist", func(t *testing.T) { value := "tel:+15555550100" parsed := template.Must(template.New("url").Parse(`x`)) var baseline bytes.Buffer if err := parsed.Execute(&baseline, value); err != nil { t.Fatal(err) } if !strings.Contains(baseline.String(), "#ZgotmplZ") { t.Fatalf("baseline URL policy changed: %q", baseline.String()) } var output bytes.Buffer if err := WriteURL(&output, value); err != nil { t.Fatalf("Sandwich Hime rejected its documented tel scheme: %v", err) } if output.String() != value { t.Fatalf("Sandwich Hime tel output = %q", output.String()) } }) } func TestHTMLTemplateDifferentialExplicitTrustedHTML(t *testing.T) { t.Parallel() value := `ok` parsed := template.Must(template.New("trusted HTML").Parse(`