1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package web
4
5import (
6 "strings"
7 "testing"
8
9 "github.com/stretchr/testify/assert"
10 "github.com/stretchr/testify/require"
11)
12
13func renderMD(t *testing.T, src string) (string, []Heading) {
14 t.Helper()
15 h, outline, err := newMarkdown().render([]byte(src), "demo", "main", "docs")
16 require.NoError(t, err)
17 return string(h), outline
18}
19
20// AC4: raw HTML, javascript: links and SVG are neutralised.
21func TestMarkdownNeutralisation(t *testing.T) {
22 tests := []struct {
23 name, src string
24 absent []string
25 present []string
26 }{
27 {
28 "raw html block", "<script>alert(1)</script>\n\n<div onclick=\"x()\">hi</div>\n",
29 []string{"<script", "<div", "onclick", "alert(1)</"}, nil,
30 },
31 {
32 "raw inline html", "a <img src=x onerror=alert(1)> b <b>c</b> <iframe src=//evil></iframe>\n",
33 []string{"<img", "onerror", "<iframe", "<b>"}, []string{"<p>a "},
34 },
35 {
36 "javascript link", "[x](javascript:alert(1)) [y](JaVaScRiPt:alert(1)) [z](java\tscript:alert(1))\n",
37 []string{"javascript", "JaVaScRiPt", "href=\"java"}, []string{"<p>x y "},
38 },
39 {
40 "data and other schemes", "[a](data:text/html,<script>1</script>) [b](vbscript:x) [c](file:///etc/passwd) [d](mailto:a@b.c) [e](//evil.example/x) [f](ftp://h/x)\n",
41 []string{"href=\"data", "vbscript", "file:", "mailto", "evil.example", "ftp:"}, []string{"<p>a b c d e f</p>"},
42 },
43 {
44 "autolinks", "<javascript:alert(1)> <https://ok.example/a> <me@example.org>\n",
45 []string{"href=\"javascript", "mailto:"}, []string{`href="https://ok.example/a"`, "me@example.org"},
46 },
47 {
48 "svg and images", "  \n\n<svg onload=alert(1)><circle/></svg>\n",
49 []string{"<img", "<svg", "onload", "evil.example", "base64", "src="}, []string{`<span class="alt">[logo]</span>`, `<span class="alt">[remote]</span>`, `<span class="alt">[image]</span>`},
50 },
51 {
52 "escaped text", "# <b>x</b> & \"q\"\n\nplain <u>tags</u> & \"quotes\"\n",
53 []string{"<b>", "<u>"}, []string{"&", """},
54 },
55 {
56 "title injection", "[x](https://a.example \"t\\\" onmouseover=\\\"alert(1)\")\n",
57 []string{"onmouseover=\"", " title="}, []string{`href="https://a.example"`},
58 },
59 }
60 for _, tt := range tests {
61 t.Run(tt.name, func(t *testing.T) {
62 out, _ := renderMD(t, tt.src)
63 for _, s := range tt.absent {
64 assert.NotContains(t, out, s)
65 }
66 for _, s := range tt.present {
67 assert.Contains(t, out, s)
68 }
69 // Whatever the input: no inline script, no event handler, no
70 // style, no element that loads something.
71 for _, banned := range []string{"<script", "<style", "<svg", "<img", "<iframe", "<object", "<embed", "<form", " on", "style="} {
72 if banned == " on" {
73 assert.NotRegexp(t, ` on[a-z]+=`, out)
74 continue
75 }
76 assert.NotContains(t, strings.ToLower(out), banned)
77 }
78 })
79 }
80}
81
82func TestMarkdownLinks(t *testing.T) {
83 tests := []struct{ name, src, href string }{
84 {"relative from the file's directory", "[a](other.md)", `href="/demo/blob/main/docs/other.md"`},
85 {"parent", "[a](../LICENSE)", `href="/demo/blob/main/LICENSE"`},
86 {"cannot climb out of the repository", "[a](../../../../etc/passwd)", `href="/demo/blob/main/etc/passwd"`},
87 {"root relative", "[a](/src/main.go)", `href="/demo/blob/main/src/main.go"`},
88 {"fragment on a file", "[a](x.md#part)", `href="/demo/blob/main/docs/x.md#part"`},
89 {"dot is the repository", "[a](../)", `href="/demo"`},
90 {"escaped segments", "[a](a%20b/c%3Fd.md)", `href="/demo/blob/main/docs/a%20b/c%3Fd.md"`},
91 {"https is external", "[a](https://example.org/p?q=1)", `href="https://example.org/p?q=1" rel="nofollow noopener noreferrer"`},
92 {"http too", "[a](http://example.org/)", `rel="nofollow noopener noreferrer"`},
93 {"heading anchor", "[a](#Install Now)", ``},
94 {"heading anchor ok", "[a](#install)", `href="#md-install"`},
95 }
96 for _, tt := range tests {
97 t.Run(tt.name, func(t *testing.T) {
98 out, _ := renderMD(t, tt.src)
99 if tt.href == "" {
100 assert.NotContains(t, out, "<a ")
101 return
102 }
103 assert.Contains(t, out, tt.href)
104 })
105 }
106}
107
108func TestMarkdownOutline(t *testing.T) {
109 out, outline := renderMD(t, "# Title\n\n## Install `now`\n\n### Install `now`\n\ntext\n\n#### raw *em*\n")
110 assert.Equal(t, []Heading{
111 {Level: 1, ID: "md-title", Text: "Title"},
112 {Level: 2, ID: "md-install-now", Text: "Install now"},
113 {Level: 3, ID: "md-install-now-1", Text: "Install now"},
114 {Level: 4, ID: "md-raw-em", Text: "raw em"},
115 }, outline, "ids are prefixed and unique, text has no markup")
116 for _, h := range outline {
117 assert.Contains(t, out, `id="`+h.ID+`"`)
118 }
119 // A heading cannot take the id of a page element.
120 out, outline = renderMD(t, "# main\n\n# keys\n")
121 assert.Equal(t, "md-main", outline[0].ID)
122 assert.NotContains(t, out, `id="main"`)
123 assert.NotContains(t, out, `id="keys"`)
124}
125
126func TestMarkdownGFM(t *testing.T) {
127 out, _ := renderMD(t, "| a | b |\n|:-|-:|\n| 1 | 2 |\n\n~~gone~~\n\n- [x] task\n")
128 assert.Contains(t, out, `<th align="right">b</th>`, "alignment is an attribute, never a style")
129 assert.NotContains(t, out, "style=")
130 assert.Contains(t, out, "<del>gone</del>")
131 assert.Contains(t, out, `type="checkbox"`)
132}