226 lines
7.1 KiB
Go
226 lines
7.1 KiB
Go
// 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: `<p>{{.}}</p>`, prefix: `<p>`, suffix: `</p>`,
|
|
write: func(output *bytes.Buffer, value any) error { return WriteText(output, value) },
|
|
values: differentialTextValues(),
|
|
},
|
|
{
|
|
name: "quoted attribute", source: `<p title="{{.}}">x</p>`, prefix: `<p title="`, suffix: `">x</p>`,
|
|
write: func(output *bytes.Buffer, value any) error { return WriteAttr(output, value) },
|
|
values: differentialTextValues(),
|
|
},
|
|
{
|
|
name: "RCDATA", source: `<textarea>{{.}}</textarea>`, prefix: `<textarea>`, suffix: `</textarea>`,
|
|
write: func(output *bytes.Buffer, value any) error { return WriteRCDATA(output, value) },
|
|
values: differentialTextValues(),
|
|
},
|
|
{
|
|
name: "safe URL", source: `<a href="{{.}}">x</a>`, prefix: `<a href="`, suffix: `">x</a>`,
|
|
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(`<a href="{{.}}">x</a>`))
|
|
values := []string{
|
|
"javascript:alert(1)",
|
|
" JAVASCRIPT:alert(1) ",
|
|
"data:text/html,<script>alert(1)</script>",
|
|
"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(`<p>{{.}}</p>`))
|
|
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(`<a href="{{.}}">x</a>`))
|
|
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(`<a href="{{.}}">x</a>`))
|
|
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 := `<strong data-note="reviewed & trusted">ok</strong>`
|
|
parsed := template.Must(template.New("trusted HTML").Parse(`<div>{{.}}</div>`))
|
|
var baseline bytes.Buffer
|
|
if err := parsed.Execute(&baseline, template.HTML(value)); err != nil { // #nosec G203 -- the test is the explicit trust-boundary comparison.
|
|
t.Fatal(err)
|
|
}
|
|
var output bytes.Buffer
|
|
output.WriteString("<div>")
|
|
if err := WriteText(&output, TrustHTML(value)); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
output.WriteString("</div>")
|
|
if output.String() != baseline.String() {
|
|
t.Fatalf("trusted HTML mismatch\nhtml/template: %q\nSandwich Hime: %q", baseline.String(), output.String())
|
|
}
|
|
}
|
|
|
|
func differentialTextValues() []string {
|
|
return []string{
|
|
"",
|
|
"ordinary text",
|
|
`<script>alert("x")</script>`,
|
|
`quotes: "double" and 'single' & ampersand`,
|
|
"Unicode: 雪 🥪 e\u0301",
|
|
"NUL:\x00:end",
|
|
"line separators: \u2028\u2029",
|
|
}
|
|
}
|
|
|
|
func FuzzWriteURLPolicy(f *testing.F) {
|
|
for _, seed := range []string{
|
|
"",
|
|
"/relative?one=1&two=2",
|
|
"https://example.test/path",
|
|
" JAVASCRIPT:alert(1) ",
|
|
"https:\n//example.test/",
|
|
"tel:+15555550100",
|
|
"invalid:\xff",
|
|
} {
|
|
f.Add(seed)
|
|
}
|
|
f.Fuzz(func(t *testing.T, value string) {
|
|
if len(value) > 64<<10 {
|
|
t.Skip()
|
|
}
|
|
var first, second bytes.Buffer
|
|
firstErr := WriteURL(&first, value)
|
|
secondErr := WriteURL(&second, value)
|
|
if first.String() != second.String() || fmt.Sprint(firstErr) != fmt.Sprint(secondErr) {
|
|
t.Fatal("URL policy was not deterministic")
|
|
}
|
|
if firstErr != nil {
|
|
if !errors.Is(firstErr, ErrUnsafeURL) || first.Len() != 0 {
|
|
t.Fatalf("URL rejection was not fail-closed: output=%q err=%v", first.String(), firstErr)
|
|
}
|
|
return
|
|
}
|
|
if !utf8.ValidString(first.String()) || strings.ContainsAny(first.String(), "\x00\r\n") {
|
|
t.Fatalf("accepted URL output is not valid single-line UTF-8: %q", first.String())
|
|
}
|
|
})
|
|
}
|