Files
sm-journal-plugin/classes/check_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

67 lines
3.0 KiB
Go

package classes
import "testing"
func TestCheckHTML(t *testing.T) {
cases := []struct {
name string
in string
ok bool
}{
{"empty", "", true},
{"heading and link", `<h2>Cordyceps</h2><p>Plain <a href="https://example.test/a">link</a></p>`, true},
{"table", `<table><thead><tr><th>A</th></tr></thead><tbody><tr><td>1</td></tr></tbody></table>`, true},
{"polish date label", `<p>Data: 12 maja 2024</p>`, true},
{"metadata text", `<p>Some metadata: here</p>`, true},
{"quotes, apostrophes and nbsp", `<h2>Co to jest Lion's Mane?&nbsp;</h2><p>"superior herb" &amp; inne</p>`, true},
{"pasted chat transcript", `<div data-message-author-role="assistant" data-message-id="cf51192a-db36-4816-8d29-b0606b53d289" dir="auto"><p>x</p></div>`, true},
{"script", `<script>alert(1)</script>`, false},
{"iframe", `<iframe src="https://example.test/"></iframe>`, false},
{"object", `<object data=x>`, false},
{"embed", `<embed src=x>`, false},
{"event handler", `<img src=x onerror="alert(1)">`, false},
{"event handler without a space", `<p><img src="x"onerror="alert(1)"></p>`, false},
{"slash-separated svg onload", "<div>\n<svg/onload=alert(1)>\n</div>", false},
{"javascript url", `<a href="javascript:alert(1)">`, false},
{"numeric entity colon", `<a href="javascript&#58;alert(1)">x</a>`, false},
{"named entity colon", `<a href="javascript&colon;alert(1)">x</a>`, false},
{"entity inside the scheme", `<a href="&#106;avascript:alert(1)">x</a>`, false},
{"tab inside the scheme", "<a href=\"java\tscript:alert(1)\">x</a>", false},
{"vbscript url mixed case", `<a href="VBScript:x">`, false},
{"data url", `<img src="data:image/png;base64,AAAA">`, false},
{"base", `<base href="https://evil.test/">`, false},
{"meta refresh", `<meta http-equiv="refresh" content="0;url=https://evil.test">`, false},
{"form", `<form action="https://evil.test"><input type=password></form>`, false},
{"style element", `<style>p { color: red }</style>`, false},
{"comment", `<p>a</p><!-- hidden -->`, false},
{"duplicate href", `<p><a href="https://x" href="javascript:alert(1)">x</a></p>`, false},
{"duplicate src", `<img src=ok src="javascript:alert(1)">`, false},
{"duplicate style", `<p style="color: red" style="position: fixed">x</p>`, false},
{"duplicate of an allowed attribute", `<p class="a" class="a">x</p>`, false},
{"attribute names inside a value", `<p><a href="https://example.test/?src=a&amp;href=b">x</a></p>`, true},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
err := CheckHTML(tc.in)
if tc.ok && err != nil {
t.Fatalf("CheckHTML(%q) = %v, want nil", tc.in, err)
}
if !tc.ok && err == nil {
t.Fatalf("CheckHTML(%q) = nil, want an error", tc.in)
}
})
}
// Every stored-XSS payload FormatHTML strips is refused by CheckHTML.
for _, tc := range unsafePayloads {
if tc.src[0] != '<' {
continue // markdown, not HTML
}
t.Run("payload "+tc.name, func(t *testing.T) {
if err := CheckHTML(tc.src); err == nil {
t.Fatalf("CheckHTML(%q) = nil, want an error", tc.src)
}
})
}
}