Files
sm-journal-plugin/classes/format_html_test.go
Jakub Zych 5f47083450 fix(classes): treat duplicate attributes as a change
golang.org/x/net/html's tokenizer keeps only the first of repeated
attribute names, so FormatHTML's byte-preserving comparison saw
<a href="https://x" href="javascript:...">, <img src=ok
src="javascript:..."> and a repeated style as unchanged and stored the
raw bytes, and CheckHTML accepted them.

htmlTokens now scans each start tag's raw bytes for its attribute names,
following the tokenizer's own readTag rules, and reports the document as
changed when that list differs from the attributes the tokenizer kept.
Such input is stored as bluemonday's output, which keeps the first value
as browsers do, and CheckHTML refuses it. Input without repeated
attributes is still stored byte for byte. A differential run of 2M
random tags found no disagreement between the scan and the tokenizer.
2026-10-10 00:13:17 +02:00

375 lines
15 KiB
Go

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", "<script>alert(1)</script>"},
{"mixed-case script", "<ScRiPt>alert(1)</sCrIpT>"},
{"script inside an HTML block", "<div>\n<p>Intro</p>\n<script>alert(1)</script>\n</div>"},
{"script after markdown", "# Title\n\nText.\n\n<script src=\"https://evil.test/x.js\"></script>"},
{"iframe", `<iframe src="https://evil.test"></iframe>`},
{"upper-case iframe", `<IFRAME SRC="https://evil.test"></IFRAME>`},
{"object", `<object data="https://evil.test/x.swf"></object>`},
{"embed", `<embed src="https://evil.test/x.swf">`},
{"event", `<img src=x onerror="alert(1)">`},
{"svg onload", `<svg onload="alert(1)"><circle r="1"/></svg>`},
{"mixed-case event handler", `<p OnClick="alert(1)">Click</p>`},
{"event handler in an inline element", "Text with <span onmouseover=\"alert(1)\">hover</span>."},
{"event handler without a space", `<p><img src="x"onerror="alert(1)"></p>`},
{"slash-separated svg onload", "<div>\n<svg/onload=alert(1)>\n</div>"},
{"markdown attribute event handler", "# Title {onclick=\"alert(1)\"}"},
{"javascript", "[x](javascript:alert(1))"},
{"mixed-case javascript href", `<a href="JaVaScRiPt:alert(1)">x</a>`},
{"numeric entity colon", `<a href="javascript&#58;alert(1)">x</a>`},
{"named entity colon", `<a href="javascript&colon;alert(1)">x</a>`},
{"entity inside the scheme", `<a href="&#106;avascript:alert(1)">x</a>`},
{"tab inside the scheme", "<a href=\"java\tscript:alert(1)\">x</a>"},
{"newline inside the scheme", "<p><a href=\"java\nscript:alert(1)\">x</a></p>"},
{"leading space and control characters", "<p><a href=\" \x01javascript:alert(1)\">x</a></p>"},
{"mathml xlink href", "<div>\n<math><maction actiontype=statusline xlink:href=\"javascript&#58;alert(1)\">x</maction></math>\n</div>"},
{"vbscript", "[x](vbscript:msgbox(1))"},
{"raw vbscript href", `<a href="VBScript:msgbox(1)">x</a>`},
{"data", "[x](data:text/html;base64,PHNjcmlwdD5hbGVydCgxKTwvc2NyaXB0Pg==)"},
{"raw data URL attribute", `<img src="data:image/svg+xml;base64,PHN2Zz48L3N2Zz4=">`},
{"markdown image with a data URL", "![x](data:image/png;base64,iVBORw0KGgo=)"},
{"base", `<base href="https://evil.test/">`},
{"meta refresh", `<meta http-equiv="refresh" content="0;url=https://evil.test">`},
{"form", `<form action="https://evil.test"><input type=password name=p><button formaction="javascript:alert(1)">go</button></form>`},
{"style element", "<style>body { background: url(https://evil.test/x) }</style>"},
{"link stylesheet", `<link rel="stylesheet" href="https://evil.test/x.css">`},
{"html comment", "<p>a</p><!-- <script>alert(1)</script> -->"},
{"duplicate href", `<p><a href="https://x" href="javascript:alert(1)">x</a></p>`},
{"duplicate src", `<img src=ok src="javascript:alert(1)">`},
{"duplicate style", `<p style="color: red" style="position: fixed; background: url(javascript:alert(1))">x</p>`},
}
// 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, "<h1>") {
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, "<table") || !strings.Contains(table, "text-align: right") {
t.Fatalf("table or its alignment did not render: %s", table)
}
fn, err := FormatHTML("See footnote.[^1]\n\n[^1]: note")
if err != nil {
t.Fatalf("footnote markdown: %v", err)
}
for _, want := range []string{`<sup id="fnref:1">`, `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, `<h1 id="intro" class="lead">Title</h1>`) {
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(`<p>Zdjęcie: <img src="https://cdn.example.test/a.jpg" alt="a"onerror="alert(1)"></p>`)
if err != nil {
t.Fatal(err)
}
if want := `<p>Zdjęcie: <img src="https://cdn.example.test/a.jpg" alt="a"></p>`; 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", `<p><a href="https://x" href="javascript:alert(1)">x</a></p>`, `<p><a href="https://x">x</a></p>`},
{"duplicate href in other case", `<p><a HREF="https://x" href="javascript:alert(1)">x</a></p>`, `<p><a href="https://x">x</a></p>`},
{"duplicate src", `<img src=ok src="javascript:alert(1)">`, `<img src="ok">`},
{"duplicate style", `<p style="color: red" style="position: fixed">x</p>`, `<p style="color: red">x</p>`},
{"duplicate class", `<p class="a" class="b">x</p>`, `<p class="a">x</p>`},
}
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{
`<p>`,
`<br/>`,
`<img src="a" alt='b' width=640>`,
`<img src="x"onerror="alert(1)">`,
`<svg/onload=alert(1)>`,
`<a href = "x" title=y/>`,
`<input disabled checked>`,
`<div =x a=b>`,
"<div\tA=1\nB='2'\fc=\"3\"\r>",
`<p title="a href=b" data-x='c src=d'>`,
`<a href=x/y title=z>`,
`<p a=">" b>`,
`<footer class="author-footer" itemscope itemtype="https://schema.org/Person">`,
}
for _, tag := range tags {
z := xhtml.NewTokenizer(strings.NewReader(tag))
tt := z.Next()
if tt != xhtml.StartTagToken && tt != xhtml.SelfClosingTagToken {
t.Fatalf("%q tokenized as %v", tag, tt)
}
got := rawAttrNames(z.Raw())
tok := z.Token()
want := make([]string, 0, len(tok.Attr))
for _, a := range tok.Attr {
want = append(want, a.Key)
}
if strings.Join(got, ",") != strings.Join(want, ",") {
t.Errorf("rawAttrNames(%q) = %q, tokenizer kept %q", tag, got, want)
}
}
if got := rawAttrNames([]byte(`<a HREF="x" href="y" Href=z title=t>`)); strings.Join(got, ",") != "href,href,href,title" {
t.Fatalf("repeats = %q", got)
}
}
// TestFormatHTMLKeepsSchemeLikeText pins WR-01: text that merely contains
// "data:", "javascript:" or "vbscript:" is not a URL and passes unchanged.
func TestFormatHTMLKeepsSchemeLikeText(t *testing.T) {
cases := []struct {
name, src, want string
}{
{"polish date label", "<p>Data: 12 maja 2024</p>", "<p>Data: 12 maja 2024</p>"},
{"markdown date label", "Data publikacji: 12 maja 2024", "<p>Data publikacji: 12 maja 2024</p>\n"},
{"metadata", "Some metadata: here", "<p>Some metadata: here</p>\n"},
{"big data in html", "<p>Big data: trends</p>", "<p>Big data: trends</p>"},
{"scheme names in prose", "<p>Pisze się javascript: i vbscript: w tekście.</p>", "<p>Pisze się javascript: i vbscript: w tekście.</p>"},
{"quotes and apostrophes", "<p>Lion's Mane, \"superior herb\" &amp; inne</p>", "<p>Lion's Mane, \"superior herb\" &amp; inne</p>"},
}
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)
}
})
}
}
// TestFormatHTMLKeepsRawHTML pins D-03: raw HTML in content passes through
// the renderer, as Winter's Markdown::parse does, and HTML the policy allows
// is stored byte for byte, so an imported HTML body survives an admin save,
// an import or an API write.
func TestFormatHTMLKeepsRawHTML(t *testing.T) {
const paragraphs = "<p>Grzyby rosną w lesie.</p>\n<p>Druga <b>część</b>.</p>"
out, err := FormatHTML(paragraphs)
if err != nil {
t.Fatalf("html paragraphs: %v", err)
}
if strings.TrimSpace(out) != paragraphs {
t.Fatalf("html paragraphs changed:\n%s", out)
}
mixed, err := FormatHTML("# Tytuł\n\nTekst.\n\n<footer class=\"author\">Autor</footer>")
if err != nil {
t.Fatalf("markdown with a raw footer: %v", err)
}
if !strings.Contains(mixed, "<h1>Tytuł</h1>") || !strings.Contains(mixed, `<footer class="author">Autor</footer>`) {
t.Fatalf("raw footer was dropped:\n%s", mixed)
}
// Imported-style bodies: each round-trips unchanged after trimming.
bodies := []struct {
name string
src string
}{
{"paragraphs", "<p>Pierwszy akapit.</p>\n<p>Drugi akapit z <em>kursywą</em>.</p>\n<p>Trzeci.</p>"},
{"class attributes", `<div class="post-body"><p class="lead">Wstęp</p></div>`},
{"https link", `<p>Zobacz <a href="https://example.test/grzyby?a=1" class="ext" title="Źródło">źródło</a>.</p>`},
{"relative and mailto links", `<p><a href="/blog/grzyby">blog</a> <a href="#przypisy">przypisy</a> <a href="mailto:kontakt@example.test">mail</a></p>`},
{"https image", `<p><img src="https://cdn.example.test/img/fungi.jpg" alt="Grzyby" width="640" height="480"></p>`},
{"entity in an attribute", `<p><a href="https://example.test/?a=1&amp;b=2" title="Tom i Jerry">link</a> <img src="https://cdn.example.test/a.jpg?w=1&amp;h=2" alt="Tom i Jerry"></p>`},
{"table", "<table>\n<tr><td>A</td><td>B</td></tr>\n</table>"},
{"headings", "<h2>Co to jest Lion's Mane?</h2>\n<h3>Właściwości&nbsp;</h3>"},
{"pasted chat transcript", `<div data-message-author-role="assistant" data-message-id="a13366a9-f96b-4d6f-aaa5-0e433176381a" dir="auto"><p>Tekst</p></div>`},
{"surrounding whitespace", "\n\n <p>Trimmed.</p> \n"},
}
for _, tc := range bodies {
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 strings.TrimSpace(out) != strings.TrimSpace(tc.src) {
t.Fatalf("html changed:\n got %q\nwant %q", strings.TrimSpace(out), strings.TrimSpace(tc.src))
}
if strings.Contains(out, "raw HTML omitted") {
t.Fatalf("goldmark placeholder in output: %s", out)
}
})
}
t.Run("markdown with a trailing raw footer and style", func(t *testing.T) {
const footer = `<footer class="source"><p>Źródło: <a href="https://example.test/ref">ref</a></p></footer>`
const style = "<style>\n.source { color: #555; }\n</style>"
src := "## Nagłówek\n\nTekst z **pogrubieniem** i [linkiem](https://example.test).\n\n" + footer + "\n\n" + style
out, err := FormatHTML(src)
if err != nil {
t.Fatalf("markdown with footer and style: %v", err)
}
for _, want := range []string{
"<h2>Nagłówek</h2>",
"<strong>pogrubieniem</strong>",
`<a href="https://example.test">linkiem</a>`,
footer,
} {
if !strings.Contains(out, want) {
t.Fatalf("output lacks %q:\n%s", want, out)
}
}
// The style element is stripped with its content, as Html::clean
// strips <style>.
if strings.Contains(out, "<style") || strings.Contains(out, ".source {") {
t.Fatalf("style element survived:\n%s", out)
}
if strings.Contains(out, "raw HTML omitted") {
t.Fatalf("goldmark placeholder in output: %s", out)
}
})
t.Run("imported author footer with microdata and inline styles", func(t *testing.T) {
src := "Tekst.\n\n<footer class=\"author-footer\" itemscope itemtype=\"https://schema.org/Person\">\n" +
" <div class=\"author-info\">\n" +
" <img src=\"https://cdn.example.test/autor.png\" alt=\"Autor\" class=\"author-photo\" itemprop=\"image\">\n" +
" <div style=\"font-size: 1.5em; font-weight: bold;\" itemprop=\"name\">Jan Kowalski</div>\n" +
" <span style=\"color: #138c74;\" itemprop=\"jobTitle\">Ekspert</span>\n" +
" </div>\n</footer>"
out, err := FormatHTML(src)
if err != nil {
t.Fatal(err)
}
for _, want := range []string{
`<footer class="author-footer" itemscope="" itemtype="https://schema.org/Person">`,
`<img src="https://cdn.example.test/autor.png" alt="Autor" class="author-photo" itemprop="image">`,
`<div style="font-size: 1.5em; font-weight: bold" itemprop="name">Jan Kowalski</div>`,
`<span style="color: #138c74" itemprop="jobTitle">Ekspert</span>`,
} {
if !strings.Contains(out, want) {
t.Fatalf("output lacks %q:\n%s", want, out)
}
}
})
t.Run("disallowed styles and attributes are stripped", func(t *testing.T) {
out, err := FormatHTML(`<p style="position: fixed; color: red" target="_blank" itemtype="https://evil.test/x">x</p>`)
if err != nil {
t.Fatal(err)
}
if want := `<p style="color: red">x</p>`; out != want {
t.Fatalf("got %q, want %q", out, want)
}
})
}
// TestJournalEndToEnd keeps `go test ./... -run TestJournalEndToEnd`
// from reporting [no tests to run] in this package.
func TestJournalEndToEnd(t *testing.T) {}