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.
This commit is contained in:
2026-10-10 00:13:17 +02:00
parent 0d00ec8e0d
commit 5f47083450
4 changed files with 207 additions and 5 deletions

View File

@@ -49,6 +49,9 @@ var unsafePayloads = []struct {
{"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.
@@ -159,6 +162,72 @@ func TestFormatHTMLSanitizesUnsafeHTML(t *testing.T) {
})
}
// 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) {