- modules/breakwater exports Render(markdown) and Purify(html): goldmark GFM in its default safe mode (raw HTML stripped, unsafe links dropped), then a named bluemonday policy built from NewPolicy, not the UGC preset - the policy is the PHP golem15/forum HTMLPurifier config 1:1: the fixed element list, a[href|rel|target], http/https/mailto only, rel=nofollow on every link, target=_blank plus noopener on fully-qualified links - tests cover the PHP ForumMarkdownRenderTest corpus, links per scheme, blank input, the Purify allow-list and concurrent use; testable examples feed the docs page snippets - module README, root module table row, introduction.md Utilities row and docs/services/markdown.md (which gate serves user content vs admin fields) - go.mod and go.sum unchanged: bluemonday v1.0.27 and goldmark v1.8.6 were already pinned
187 lines
5.9 KiB
Go
187 lines
5.9 KiB
Go
package breakwater_test
|
|
|
|
import (
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
|
|
"git.golem15.com/golem15/summercms/modules/breakwater"
|
|
)
|
|
|
|
// The corpora of the PHP golem15/forum ForumMarkdownRenderTest, verbatim.
|
|
const (
|
|
xssCorpus = "<script>alert(1)</script>\n\n[x](javascript:alert(1))\n\n<iframe src=x></iframe>\n\n<img src=x onerror=alert(1)>"
|
|
gfmCorpus = "| A | B |\n|---|---|\n| 1 | 2 |\n\n```php\necho 'wave';\n```"
|
|
)
|
|
|
|
func wantContains(t *testing.T, got string, subs ...string) {
|
|
t.Helper()
|
|
for _, s := range subs {
|
|
if !strings.Contains(got, s) {
|
|
t.Errorf("output lacks %q:\n%s", s, got)
|
|
}
|
|
}
|
|
}
|
|
|
|
func wantNotContains(t *testing.T, got string, subs ...string) {
|
|
t.Helper()
|
|
for _, s := range subs {
|
|
if strings.Contains(got, s) {
|
|
t.Errorf("output contains %q:\n%s", s, got)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestRenderXSS(t *testing.T) {
|
|
got := breakwater.Render(xssCorpus)
|
|
lower := strings.ToLower(got)
|
|
wantNotContains(t, lower, "<script", "javascript:", "<iframe", "onerror", "<img", "<script", "<!--")
|
|
wantContains(t, got, "x")
|
|
}
|
|
|
|
func TestRenderStripsRawHTML(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
in string
|
|
keeps []string
|
|
}{
|
|
{"script", "<script>alert(1)</script>", nil},
|
|
{"inline", "text <b onclick=\"x()\">bold</b> more", []string{"text", "more"}},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
got := breakwater.Render(tc.in)
|
|
wantNotContains(t, got, "alert(1)", "onclick", "<!--", "<")
|
|
wantContains(t, got, tc.keeps...)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestRenderGFM(t *testing.T) {
|
|
got := breakwater.Render(gfmCorpus)
|
|
wantContains(t, got, "<table>", "<th>A</th>", "<td>1</td>", "<pre>", "<code")
|
|
wantNotContains(t, got, "class=")
|
|
|
|
got = breakwater.Render("~~gone~~ **kept**")
|
|
wantContains(t, got, "<del>gone</del>", "<strong>kept</strong>")
|
|
|
|
got = breakwater.Render("# Title\n\n> quote\n\n- a\n- b\n\n---")
|
|
wantContains(t, got, "<h1>", "<blockquote>", "<ul>", "<li>", "<hr")
|
|
}
|
|
|
|
func TestRenderLinks(t *testing.T) {
|
|
t.Run("absolute", func(t *testing.T) {
|
|
got := breakwater.Render("[site](https://example.com/a?b=1&c=2)")
|
|
wantContains(t, got, `href="https://example.com/a?b=1&c=2"`, `target="_blank"`)
|
|
rel := relValue(t, got)
|
|
wantContains(t, rel, "nofollow", "noopener")
|
|
})
|
|
t.Run("autolink", func(t *testing.T) {
|
|
got := breakwater.Render("visit https://example.com/x now")
|
|
wantContains(t, got, `<a href="https://example.com/x"`, "nofollow", `target="_blank"`)
|
|
})
|
|
t.Run("mailto", func(t *testing.T) {
|
|
got := breakwater.Render("[mail](mailto:a@example.com)")
|
|
wantContains(t, got, `href="mailto:a@example.com"`, "nofollow")
|
|
})
|
|
dropped := []struct {
|
|
name, in, scheme, text string
|
|
}{
|
|
{"data", "[d](data:text/html;base64,AAAA)", "data:", "d"},
|
|
{"vbscript", "[v](vbscript:x)", "vbscript:", "v"},
|
|
{"tel", "[t](tel:+48123456)", "tel:", "t"},
|
|
}
|
|
for _, tc := range dropped {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
got := breakwater.Render(tc.in)
|
|
wantNotContains(t, got, "data:", "vbscript:", "tel:", "<a")
|
|
wantContains(t, got, tc.text)
|
|
})
|
|
}
|
|
t.Run("relative", func(t *testing.T) {
|
|
got := breakwater.Render("[rel](/threads/1)")
|
|
wantNotContains(t, got, "href")
|
|
wantContains(t, got, "rel")
|
|
})
|
|
}
|
|
|
|
// relValue returns the value of the first rel attribute in html.
|
|
func relValue(t *testing.T, html string) string {
|
|
t.Helper()
|
|
_, after, ok := strings.Cut(html, `rel="`)
|
|
if !ok {
|
|
t.Fatalf("no rel attribute in:\n%s", html)
|
|
}
|
|
val, _, ok := strings.Cut(after, `"`)
|
|
if !ok {
|
|
t.Fatalf("unterminated rel attribute in:\n%s", html)
|
|
}
|
|
return val
|
|
}
|
|
|
|
func TestBlankInput(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
got string
|
|
}{
|
|
{"render empty", breakwater.Render("")},
|
|
{"render whitespace", breakwater.Render(" \n\t ")},
|
|
{"purify empty", breakwater.Purify("")},
|
|
{"purify whitespace", breakwater.Purify(" \n ")},
|
|
}
|
|
for _, tc := range cases {
|
|
if tc.got != "" {
|
|
t.Errorf("%s: got %q, want \"\"", tc.name, tc.got)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestPurifyAllowList(t *testing.T) {
|
|
in := `<p onclick="x()">Hi <b>b</b> <i>i</i> <img src=x onerror=alert(1)><script>alert(1)</script></p>` +
|
|
`<figure><img src="a.jpg"></figure><h2 class="t" id="h">H</h2>` +
|
|
`<a href="https://e.com/p" target="_top" rel="opener">l</a><a href="tel:+48">t</a>` +
|
|
`<sub>s</sub><sup>p</sup><ins>i</ins><del>d</del><hr>` +
|
|
`<table><thead><tr><th>h</th></tr></thead><tbody><tr><td>c</td></tr></tbody></table>` +
|
|
`<blockquote><pre><code>c < d</code></pre></blockquote>` +
|
|
`<ul><li>u</li></ul><ol><li>o</li></ol><h6>6</h6><br>`
|
|
got := breakwater.Purify(in)
|
|
wantContains(t, got,
|
|
"<p>", "<b>b</b>", "<i>i</i>", "<h2>H</h2>", "<sub>s</sub>", "<sup>p</sup>", "<ins>i</ins>", "<del>d</del>",
|
|
"<hr", "<table>", "<thead>", "<th>h</th>", "<tbody>", "<td>c</td>", "<blockquote>", "<pre>",
|
|
"<code>c < d</code>", "<ul>", "<ol>", "<li>", "<h6>6</h6>", "<br",
|
|
`href="https://e.com/p"`, `target="_blank"`,
|
|
)
|
|
rel := relValue(t, got)
|
|
wantContains(t, rel, "nofollow", "noopener")
|
|
lower := strings.ToLower(got)
|
|
wantNotContains(t, lower, "onclick", "onerror", "<img", "<script", "<figure", "class=", "id=",
|
|
`target="_top"`, `rel="opener"`, "tel:", "alert(")
|
|
// The text of the dropped tel link survives.
|
|
wantContains(t, got, "</a>t<sub>")
|
|
|
|
got = breakwater.Purify("<p>plain</p><!-- c --><style>p{}</style><form><input></form><svg onload=x></svg><iframe src=x></iframe>")
|
|
wantContains(t, got, "<p>plain</p>")
|
|
wantNotContains(t, got, "<!--", "<style", "<form", "<input", "<svg", "onload", "<iframe")
|
|
}
|
|
|
|
func TestConcurrentUse(t *testing.T) {
|
|
wantRender := breakwater.Render(gfmCorpus)
|
|
wantPurify := breakwater.Purify(wantRender)
|
|
var wg sync.WaitGroup
|
|
for i := 0; i < 8; i++ {
|
|
wg.Add(1)
|
|
go func() {
|
|
defer wg.Done()
|
|
for j := 0; j < 20; j++ {
|
|
if got := breakwater.Render(gfmCorpus); got != wantRender {
|
|
t.Errorf("concurrent Render differs:\n%s\nwant:\n%s", got, wantRender)
|
|
}
|
|
if got := breakwater.Purify(wantRender); got != wantPurify {
|
|
t.Errorf("concurrent Purify differs:\n%s\nwant:\n%s", got, wantPurify)
|
|
}
|
|
}
|
|
}()
|
|
}
|
|
wg.Wait()
|
|
}
|