Files
summercms/modules/seaglass/seaglass_test.go
Jakub Zych 91593da1f7 test(17-06): framework and admin SPA unit tests for the blog companions
- signpost: activation refusals, host overrides, D-02 pattern layers
  incl. PatternFrom, ReferenceOptions depths
- seaglass: removal report, bluemonday bypass corpus, Check
- pact Option.Depth omitempty and the menu item type contracts; party
  activates menu item types after Register and before the first Boot
- cabana: repeater compile and decode, dependsOn compile and recheck,
  richeditor keys and notices, reorder and singleton boot checks
  (Docker-free); repeater save, dependent recheck, richeditor save,
  reorder and singleton routes on Postgres; conformance cases for the
  documented 404, 409 and 422 answers of the new routes
- admin: richeditor value contract, link popover, notices and chunk
  failure; repeater bounds, remove and reorder, collapsed rows;
  dependent dropdown states and last request wins; reorder
  announcements, partition switch and tree rules; SingletonForm spec
- gate: required names in the go, admin and postgres stages
2026-10-12 19:29:37 +02:00

211 lines
9.3 KiB
Go

package seaglass_test
import (
"slices"
"strings"
"testing"
"git.golem15.com/golem15/summercms/modules/seaglass"
)
// TestSanitizeByteStable pins D-10's byte-stability promise: HTML the policy
// keeps whole comes back byte for byte, with attributes in their source order,
// entities as written, whitespace and quoting untouched, and a zero report.
func TestSanitizeByteStable(t *testing.T) {
for _, html := range []string{
"",
"plain text",
"<p>One paragraph.</p>",
"<p>Mushrooms <strong>can</strong> <em>help</em>.</p>\n\n<p>Second.</p>",
`<a title="t" href="https://example.com/a?b=1&amp;c=2" class="x y">attributes in source order</a>`,
`<a class="x" href="/kontakt">relative</a> <a href="mailto:a@example.com">mail</a> <a href="tel:+48123456">tel</a>`,
`<p>Entities: &amp; &lt;tag&gt; &quot;q&quot; &#39;s&#39; &nbsp; &copy; &#x2014; &#8212;</p>`,
`<p>Raw text quotes: It's "fine" &amp; done</p>`,
`<p class='single-quoted'>single quotes stay</p>`,
`<p CLASS="upper">upper-case attribute name</p>`,
"<ul>\n <li>one</li>\n <li>two</li>\n</ul>",
"<ol><li>a<ol><li>nested</li></ol></li></ol>",
`<h2 dir="rtl">heading</h2><h3>h3</h3><blockquote><p>quote</p></blockquote>`,
`<p>line<br>break<br/>self-closed</p>`,
`<img src="/media/a.jpg" alt="An image" width="10" height="20">`,
`<table><thead><tr><th>h</th></tr></thead><tbody><tr><td>c</td></tr></tbody></table>`,
`<p style="text-align: center; color: red">styled</p>`,
`<div itemscope itemtype="https://schema.org/Recipe"><span itemprop="name">Tea</span></div>`,
`<p><sup><a href="#fn1" role="doc-noteref">1</a></sup></p><div role="doc-endnotes"><p id="fn1">note</p></div>`,
`<pre><code>if a &lt; b { return }</code></pre>`,
"<p>Zażółć gęślą jaźń — unicode “quotes”</p>",
} {
got, report := seaglass.SanitizeReport(html)
if got != html {
t.Errorf("SanitizeReport(%q) = %q, want the input byte for byte", html, got)
}
if report.Changed || report.ElementCount != 0 || report.AttributeCount != 0 || report.Comments != 0 || len(report.Elements) != 0 || len(report.Attributes) != 0 {
t.Errorf("SanitizeReport(%q) report = %+v, want zero", html, report)
}
if out := seaglass.Sanitize(html); out != html {
t.Errorf("Sanitize(%q) = %q", html, out)
}
if err := seaglass.Check(html); err != nil {
t.Errorf("Check(%q) = %v", html, err)
}
}
}
// TestSanitizeReport pins the removal report: element, attribute and comment
// names and counts, the ten-name cap with its exact counts, a repeated
// attribute, a style that lost a property, and the sanitized output itself.
func TestSanitizeReport(t *testing.T) {
for _, tc := range []struct {
name string
in, out string
elements []string
elemCount int
attributes []string
attrCount int
comments int
}{
{
name: "element, attribute and comments",
in: `<p onclick="a()" class="x">Hi</p><script>x</script><!-- c --><!-- d -->`,
out: `<p class="x">Hi</p>`, elements: []string{"script"}, elemCount: 1, attributes: []string{"onclick"}, attrCount: 1, comments: 2,
},
{
name: "names are distinct in first-seen order, counts are exact",
in: `<p onclick="a()">1</p><p onclick="b()" onmouseover="c()">2</p><script>1</script><script>2</script>`,
out: `<p>1</p><p>2</p>`, elements: []string{"script"}, elemCount: 2, attributes: []string{"onclick", "onmouseover"}, attrCount: 3,
},
{
name: "ten names then an ellipsis, the count stays exact",
in: `<iframe></iframe><object></object><embed><form></form><input><base><meta><link><svg></svg><math></math><button></button><select></select>`,
out: "", elements: []string{"iframe", "object", "embed", "form", "input", "base", "meta", "link", "svg", "math", "…"}, elemCount: 12,
},
{
name: "a repeated attribute counts as removed",
in: `<a href="/a" href="/b">x</a>`, out: `<a href="/a">x</a>`, attributes: []string{"href"}, attrCount: 1,
},
{
name: "a style that lost a property counts under style",
in: `<p style="color: red; position: fixed">x</p>`, out: `<p style="color: red">x</p>`, attributes: []string{"style"}, attrCount: 1,
},
{
name: "target and rel are removed",
in: `<a href="https://ok" target="_blank" rel="opener">x</a>`, out: `<a href="https://ok">x</a>`, attributes: []string{"target", "rel"}, attrCount: 2,
},
{
name: "a link without an allowed href loses its tag, not its text",
in: `<p><a href="javascript:alert(1)">x</a> y</p>`, out: `<p>x y</p>`, elements: []string{"a"}, elemCount: 1,
},
{
name: "only comments",
in: `<p>a</p><!-- one -->`, out: `<p>a</p>`, comments: 1,
},
} {
t.Run(tc.name, func(t *testing.T) {
got, r := seaglass.SanitizeReport(tc.in)
if got != tc.out {
t.Fatalf("output = %q, want %q", got, tc.out)
}
if !r.Changed {
t.Fatal("Changed = false")
}
if !slices.Equal(r.Elements, tc.elements) || r.ElementCount != tc.elemCount {
t.Fatalf("elements = %v (%d), want %v (%d)", r.Elements, r.ElementCount, tc.elements, tc.elemCount)
}
if !slices.Equal(r.Attributes, tc.attributes) || r.AttributeCount != tc.attrCount {
t.Fatalf("attributes = %v (%d), want %v (%d)", r.Attributes, r.AttributeCount, tc.attributes, tc.attrCount)
}
if r.Comments != tc.comments {
t.Fatalf("comments = %d, want %d", r.Comments, tc.comments)
}
if seaglass.Sanitize(tc.in) != tc.out {
t.Fatal("Sanitize differs from SanitizeReport")
}
// The sanitized output is itself clean and stable.
again, r2 := seaglass.SanitizeReport(got)
if again != got || r2.Changed {
t.Fatalf("sanitizing the output changed it: %q %+v", again, r2)
}
})
}
}
// TestSanitizeBypassCorpus runs the payloads the bluemonday decision notes
// list as bypasses of the regex gate it replaced (no space before an event
// handler, entity-encoded and whitespace-split javascript schemes, svg
// onload, MathML xlink:href, base, meta refresh, form, data and vbscript
// URLs, a repeated href) plus the ordinary unsafe elements: each comes back
// without the dangerous construct, with Changed set, and Check refuses it.
func TestSanitizeBypassCorpus(t *testing.T) {
for _, tc := range []struct {
payload string
banned []string
}{
{`<img src="x"onerror="alert(1)">`, []string{"onerror", "alert"}},
{`<img src=x onerror=alert(1)>`, []string{"onerror", "alert"}},
{`<p onmouseover=alert(1)>x</p>`, []string{"onmouseover", "alert"}},
{`<svg/onload=alert(1)>`, []string{"<svg", "onload", "alert"}},
{`<a href="javascript&#58;alert(1)">x</a>`, []string{"href", "javascript", "alert"}},
{`<a href="javascript&colon;alert(1)">x</a>`, []string{"href", "javascript", "alert"}},
{`<a href="&#106;avascript:alert(1)">x</a>`, []string{"href", "avascript", "alert"}},
{"<a href=\"java\tscript:alert(1)\">x</a>", []string{"href", "script", "alert"}},
{`<a HREF="JaVaScRiPt:alert(1)">x</a>`, []string{"href", "javascript", "alert"}},
{`<math><mi xlink:href="javascript:alert(1)">x</mi></math>`, []string{"<math", "<mi", "xlink", "javascript"}},
{`<base href="https://evil.example/">`, []string{"<base", "evil.example"}},
{`<meta http-equiv="refresh" content="0;url=https://evil.example">`, []string{"<meta", "refresh", "evil.example"}},
{`<form action="https://evil.example"><input name="q"></form>`, []string{"<form", "<input", "evil.example"}},
{`<a href="data:text/html;base64,PHNjcmlwdD4=">x</a>`, []string{"href", "data:"}},
{`<img src="data:image/svg+xml;base64,PHN2Zz4=">`, []string{"<img", "data:"}},
{`<a href="vbscript:msgbox(1)">x</a>`, []string{"href", "vbscript"}},
{`<a href="https://a" href="javascript:alert(1)">x</a>`, []string{"javascript", "alert"}},
{`<div style="background:url(javascript:alert(1))">x</div>`, []string{"style", "javascript"}},
{`<iframe src="https://evil.example"></iframe>`, []string{"<iframe", "evil.example"}},
{`<p>x</p><script>alert(1)</script>`, []string{"<script", "alert"}},
{`<p>x</p><style>p{display:none}</style>`, []string{"<style", "display"}},
{`<object data="x.swf"></object><embed src="x.swf">`, []string{"<object", "<embed", "swf"}},
} {
t.Run(tc.payload, func(t *testing.T) {
got, r := seaglass.SanitizeReport(tc.payload)
lower := strings.ToLower(got)
for _, bad := range tc.banned {
if strings.Contains(lower, strings.ToLower(bad)) {
t.Fatalf("%q survived: %q", bad, got)
}
}
if !r.Changed {
t.Fatalf("Changed = false for %q -> %q", tc.payload, got)
}
if err := seaglass.Check(tc.payload); err == nil {
t.Fatal("Check accepted the payload")
}
})
}
}
// TestCheck pins Check: nil when the policy keeps the HTML whole (including
// text that only looks like a scheme, the WR-01 false refusals), an error
// prefixed "seaglass: " when it would change anything.
func TestCheck(t *testing.T) {
for _, html := range []string{
"",
"<p>Data: 12 maja 2024, metadata: none</p>",
"<p>javascript: is just a word here</p>",
`<p>It's "fine"</p>`,
`<a href="tel:+48123">call</a>`,
} {
if err := seaglass.Check(html); err != nil {
t.Errorf("Check(%q) = %v", html, err)
}
}
for _, html := range []string{
"<!-- a comment -->",
`<a href="https://a" href="https://b">dup</a>`,
`<p style="position: fixed">x</p>`,
`<a href="ftp://example.com/x">ftp</a>`,
} {
err := seaglass.Check(html)
if err == nil || !strings.HasPrefix(err.Error(), "seaglass: ") {
t.Errorf("Check(%q) = %v, want a seaglass error", html, err)
}
}
}