package classes
import (
"strings"
"testing"
xhtml "golang.org/x/net/html"
)
// unsafePayloads are the stored-XSS inputs FormatHTML and CheckHTML must
// neutralize: the JOURNAL-003/004 Twig XSS templates (Phase 16) and the
// regex-gate bypasses from the Phase 16.1 code review (CR-01).
var unsafePayloads = []struct {
name string
src string
}{
{"script", ""},
{"mixed-case script", ""},
{"script inside an HTML block", "
"},
{"script after markdown", "# Title\n\nText.\n\n"},
{"iframe", ``},
{"upper-case iframe", ``},
{"object", ` `},
{"embed", ``},
{"event", ` `},
{"svg onload", ` `},
{"mixed-case event handler", `Click
`},
{"event handler in an inline element", "Text with hover ."},
{"event handler without a space", `
`},
{"slash-separated svg onload", "\n\n
"},
{"markdown attribute event handler", "# Title {onclick=\"alert(1)\"}"},
{"javascript", "[x](javascript:alert(1))"},
{"mixed-case javascript href", `x `},
{"numeric entity colon", `x `},
{"named entity colon", `x `},
{"entity inside the scheme", `x `},
{"tab inside the scheme", "x "},
{"newline inside the scheme", "x
"},
{"leading space and control characters", "x
"},
{"mathml xlink href", "\nx \n
"},
{"vbscript", "[x](vbscript:msgbox(1))"},
{"raw vbscript href", `x `},
{"data", "[x](data:text/html;base64,PHNjcmlwdD5hbGVydCgxKTwvc2NyaXB0Pg==)"},
{"raw data URL attribute", ` `},
{"markdown image with a data URL", ""},
{"base", ` `},
{"meta refresh", ` `},
{"form", ``},
{"style element", ""},
{"link stylesheet", ` `},
{"html comment", "a
"},
{"duplicate href", `x
`},
{"duplicate src", ` `},
{"duplicate style", `x
`},
}
// forbiddenElements must never appear in sanitized journal HTML.
var forbiddenElements = map[string]bool{
"script": true, "iframe": true, "object": true, "embed": true, "base": true,
"meta": true, "form": true, "input": true, "button": true, "style": true,
"link": true, "svg": true, "math": true, "maction": true, "frame": true,
"frameset": true, "applet": true, "textarea": true, "select": true,
}
// assertNoActiveContent tokenizes doc and fails on forbidden elements, event
// handler attributes, any style other than the allowed properties, and
// javascript, vbscript or data URLs after entity decoding and after removing
// ASCII whitespace and control characters, as a browser's URL parser does.
func assertNoActiveContent(t *testing.T, doc string) {
t.Helper()
z := xhtml.NewTokenizer(strings.NewReader(doc))
for {
tt := z.Next()
if tt == xhtml.ErrorToken {
return
}
if tt == xhtml.CommentToken {
t.Fatalf("comment survived: %q", doc)
}
if tt != xhtml.StartTagToken && tt != xhtml.SelfClosingTagToken && tt != xhtml.EndTagToken {
continue
}
tok := z.Token()
if forbiddenElements[tok.Data] {
t.Fatalf("element <%s> survived: %q", tok.Data, doc)
}
for _, a := range tok.Attr {
key := strings.ToLower(a.Key)
if strings.HasPrefix(key, "on") {
t.Fatalf("event handler %s survived: %q", a.Key, doc)
}
if key == "action" || key == "formaction" || key == "srcdoc" || key == "http-equiv" {
t.Fatalf("attribute %s survived: %q", a.Key, doc)
}
var b strings.Builder
for _, r := range strings.ToLower(a.Val) {
if r > 0x20 {
b.WriteRune(r)
}
}
v := b.String()
for _, scheme := range []string{"javascript:", "vbscript:", "data:"} {
if strings.Contains(v, scheme) {
t.Fatalf("%s URL survived in %s: %q", scheme, a.Key, doc)
}
}
}
}
}
// TestFormatHTMLSanitizesUnsafeHTML pins CR-01: every payload is rendered
// without an error and with all active content stripped, as Winter's
// Html::clean strips rather than refuses. Footnotes and tables from goldmark
// extensions still render.
func TestFormatHTMLSanitizesUnsafeHTML(t *testing.T) {
html, err := FormatHTML("# Hello")
if err != nil || !strings.Contains(html, "") {
t.Fatalf("safe heading: html=%s err=%v", html, err)
}
table, err := FormatHTML("| a | b |\n|:- | -:|\n| 1 | 2 |")
if err != nil {
t.Fatalf("table markdown: %v", err)
}
if !strings.Contains(table, " `, `href="#fn:1"`, `role="doc-noteref"`, `role="doc-endnotes"`, `role="doc-backlink"`} {
if !strings.Contains(fn, want) {
t.Fatalf("footnote lacks %s: %s", want, fn)
}
}
attrs, err := FormatHTML("# Title {#intro .lead}")
if err != nil || !strings.Contains(attrs, `Title `) {
t.Fatalf("markdown attributes: html=%s err=%v", attrs, err)
}
for _, tc := range unsafePayloads {
t.Run(tc.name, func(t *testing.T) {
out, err := FormatHTML(tc.src)
if err != nil {
t.Fatalf("FormatHTML(%q) = %v, want sanitized output", tc.src, err)
}
assertNoActiveContent(t, out)
})
}
t.Run("safe parts of a payload survive", func(t *testing.T) {
out, err := FormatHTML(`Zdjęcie:
`)
if err != nil {
t.Fatal(err)
}
if want := `Zdjęcie:
`; out != want {
t.Fatalf("got %q, want %q", out, want)
}
})
}
// TestFormatHTMLDuplicateAttributes pins W-2: the tokenizer keeps only the
// first of repeated attributes, so input that repeats one is never stored
// byte for byte. The sanitized output keeps the first value, as browsers do.
func TestFormatHTMLDuplicateAttributes(t *testing.T) {
cases := []struct {
name, src, want string
}{
{"duplicate href", `x
`, `x
`},
{"duplicate href in other case", `x
`, `x
`},
{"duplicate src", ` `, ` `},
{"duplicate style", `x
`, `x
`},
{"duplicate class", `x
`, `x
`},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
out, err := FormatHTML(tc.src)
if err != nil {
t.Fatalf("FormatHTML(%q): %v", tc.src, err)
}
if out != tc.want {
t.Fatalf("got %q, want %q", out, tc.want)
}
})
}
}
// TestRawAttrNames checks the raw attribute scan against the tokenizer: for
// tags without repeats both list the same names in the same order.
func TestRawAttrNames(t *testing.T) {
tags := []string{
``,
` `,
` `,
` `,
``,
``,
` `,
`