- 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
211 lines
9.3 KiB
Go
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&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: & <tag> "q" 's' © — —</p>`,
|
|
`<p>Raw text quotes: It's "fine" & 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 < 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:alert(1)">x</a>`, []string{"href", "javascript", "alert"}},
|
|
{`<a href="javascript:alert(1)">x</a>`, []string{"href", "javascript", "alert"}},
|
|
{`<a href="javascript: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)
|
|
}
|
|
}
|
|
}
|