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 t.Parallel()
23 tests := []struct {
24 name, src string
25 absent []string
26 present []string
27 }{
28 {
29 "raw html block", "<script>alert(1)</script>\n\n<div onclick=\"x()\">hi</div>\n",
30 []string{"<script", "<div", "onclick", "alert(1)</"}, nil,
31 },
32 {
33 "raw inline html", "a <img src=x onerror=alert(1)> b <b>c</b> <iframe src=//evil></iframe>\n",
34 []string{"<img", "onerror", "<iframe", "<b>"}, []string{"<p>a "},
35 },
36 {
37 "javascript link", "[x](javascript:alert(1)) [y](JaVaScRiPt:alert(1)) [z](java\tscript:alert(1))\n",
38 []string{"javascript", "JaVaScRiPt", "href=\"java"}, []string{"<p>x y "},
39 },
40 {
41 "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",
42 []string{"href=\"data", "vbscript", "file:", "mailto", "evil.example", "ftp:"}, []string{"<p>a b c d e f</p>"},
43 },
44 {
45 "autolinks", "<javascript:alert(1)> <https://ok.example/a> <me@example.org>\n",
46 []string{"href=\"javascript", "mailto:"}, []string{`href="https://ok.example/a"`, "me@example.org"},
47 },
48 {
49 "svg and images", "  \n\n<svg onload=alert(1)><circle/></svg>\n",
50 []string{"<img", "<svg", "onload", "evil.example", "base64", "src="}, []string{`<span class="alt">[logo]</span>`, `<span class="alt">[remote]</span>`, `<span class="alt">[image]</span>`},
51 },
52 {
53 "escaped text", "# <b>x</b> & \"q\"\n\nplain <u>tags</u> & \"quotes\"\n",
54 []string{"<b>", "<u>"}, []string{"&", """},
55 },
56 {
57 "title injection", "[x](https://a.example \"t\\\" onmouseover=\\\"alert(1)\")\n",
58 []string{"onmouseover=\"", " title="}, []string{`href="https://a.example"`},
59 },
60 }
61 for _, tt := range tests {
62 t.Run(tt.name, func(t *testing.T) {
63 t.Parallel()
64 out, _ := renderMD(t, tt.src)
65 for _, s := range tt.absent {
66 assert.NotContains(t, out, s)
67 }
68 for _, s := range tt.present {
69 assert.Contains(t, out, s)
70 }
71 // Whatever the input: no inline script, no event handler, no
72 // style, no element that loads something.
73 for _, banned := range []string{"<script", "<style", "<svg", "<img", "<iframe", "<object", "<embed", "<form", " on", "style="} {
74 if banned == " on" {
75 assert.NotRegexp(t, ` on[a-z]+=`, out)
76 continue
77 }
78 assert.NotContains(t, strings.ToLower(out), banned)
79 }
80 })
81 }
82}
83
84func TestMarkdownLinks(t *testing.T) {
85 t.Parallel()
86 tests := []struct{ name, src, href string }{
87 {"relative from the file's directory", "[a](other.md)", `href="/demo/blob/main/docs/other.md"`},
88 {"parent", "[a](../LICENSE)", `href="/demo/blob/main/LICENSE"`},
89 {"cannot climb out of the repository", "[a](../../../../etc/passwd)", `href="/demo/blob/main/etc/passwd"`},
90 {"root relative", "[a](/src/main.go)", `href="/demo/blob/main/src/main.go"`},
91 {"fragment on a file", "[a](x.md#part)", `href="/demo/blob/main/docs/x.md#part"`},
92 {"dot is the repository", "[a](../)", `href="/demo"`},
93 {"escaped segments", "[a](a%20b/c%3Fd.md)", `href="/demo/blob/main/docs/a%20b/c%3Fd.md"`},
94 {"https is external", "[a](https://example.org/p?q=1)", `href="https://example.org/p?q=1" rel="nofollow noopener noreferrer"`},
95 {"http too", "[a](http://example.org/)", `rel="nofollow noopener noreferrer"`},
96 {"heading anchor", "[a](#Install Now)", ``},
97 {"heading anchor ok", "[a](#install)", `href="#md-install"`},
98 }
99 for _, tt := range tests {
100 t.Run(tt.name, func(t *testing.T) {
101 t.Parallel()
102 out, _ := renderMD(t, tt.src)
103 if tt.href == "" {
104 assert.NotContains(t, out, "<a ")
105 return
106 }
107 assert.Contains(t, out, tt.href)
108 })
109 }
110}
111
112func TestMarkdownOutline(t *testing.T) {
113 t.Parallel()
114 out, outline := renderMD(t, "# Title\n\n## Install `now`\n\n### Install `now`\n\ntext\n\n#### raw *em*\n")
115 assert.Equal(t, []Heading{
116 {Level: 1, ID: "md-title", Text: "Title"},
117 {Level: 2, ID: "md-install-now", Text: "Install now"},
118 {Level: 3, ID: "md-install-now-1", Text: "Install now"},
119 {Level: 4, ID: "md-raw-em", Text: "raw em"},
120 }, outline, "ids are prefixed and unique, text has no markup")
121 for _, h := range outline {
122 assert.Contains(t, out, `id="`+h.ID+`"`)
123 }
124 // A heading cannot take the id of a page element.
125 out, outline = renderMD(t, "# main\n\n# keys\n")
126 assert.Equal(t, "md-main", outline[0].ID)
127 assert.NotContains(t, out, `id="main"`)
128 assert.NotContains(t, out, `id="keys"`)
129}
130
131func TestMarkdownGFM(t *testing.T) {
132 t.Parallel()
133 out, _ := renderMD(t, "| a | b |\n|:-|-:|\n| 1 | 2 |\n\n~~gone~~\n\n- [x] task\n")
134 assert.Contains(t, out, `<th align="right">b</th>`, "alignment is an attribute, never a style")
135 assert.NotContains(t, out, "style=")
136 assert.Contains(t, out, "<del>gone</del>")
137 assert.Contains(t, out, `type="checkbox"`)
138}
139
140func renderUntrustedMD(t *testing.T, src string) string {
141 t.Helper()
142 h, err := newMarkdown().renderUntrusted([]byte(src), "issue-desc-")
143 require.NoError(t, err)
144 return string(h)
145}
146
147// An issue from the public form is rendered with the strictest policy.
148func TestMarkdownUntrusted(t *testing.T) {
149 t.Parallel()
150 tests := []struct {
151 name, src string
152 absent []string
153 present []string
154 }{
155 {"script", "<script>alert(1)</script>\n", []string{"<script", "alert(1)</"}, nil},
156 {"img onerror", "a <img src=x onerror=alert(1)> b\n", []string{"<img", "onerror"}, []string{"<p>a "}},
157 {"javascript link", "[x](javascript:alert(1))\n", []string{"javascript", "href"}, []string{"<p>x</p>"}},
158 {"data link", "[x](data:text/html,<script>alert(1)</script>)\n", []string{"data:", "href", "<script"}, nil},
159 {"vbscript and file", "[a](vbscript:x) [b](file:///etc/passwd)\n", []string{"vbscript", "file:", "href"}, []string{"a b"}},
160 {"relative links lose the link", "[a](../../etc) [b](/proj/blob/main/x) [c](#frag)\n", []string{"href"}, []string{"a b c"}},
161 {"protocol relative", "[x](//evil.test/p)\n", []string{"href", "evil"}, nil},
162 {"https is kept with ugc", "[x](https://example.org/p?q=1)\n", nil,
163 []string{`href="https://example.org/p?q=1"`, `rel="nofollow noopener noreferrer ugc"`}},
164 {"http is kept", "[x](http://example.org/)\n", nil, []string{`href="http://example.org/"`, "ugc"}},
165 {"credentials are refused", "[x](https://user:pw@example.org/)\n", []string{"href", "pw"}, nil},
166 {"mailto is kept", "[x](mailto:a@example.org)\n", nil, []string{`href="mailto:a@example.org"`, "ugc"}},
167 {"mailto with query is refused", "[x](mailto:a@example.org?cc=b@example.org)\n", []string{"href"}, nil},
168 {"autolink url", "<https://example.org/a> and <javascript:alert(1)>\n", []string{"href=\"javascript"}, []string{`rel="nofollow noopener noreferrer ugc"`}},
169 {"autolink email", "<a@example.org>\n", nil, []string{`href="mailto:a@example.org"`, "ugc"}},
170 {"bare url (GFM)", "see https://example.org/x now\n", nil, []string{`href="https://example.org/x"`, "ugc"}},
171 {"image is its alt text", "\n", []string{"<img", "evil.test"}, []string{`<span class="alt">[pwn]</span>`}},
172 {"reference link to javascript", "[x][r]\n\n[r]: javascript:alert(1)\n", []string{"javascript", "href"}, nil},
173 {"entity-encoded scheme", "[x](javascript:alert(1))\n", []string{"avascript:", "href"}, nil},
174 }
175 for _, tt := range tests {
176 t.Run(tt.name, func(t *testing.T) {
177 t.Parallel()
178 out := renderUntrustedMD(t, tt.src)
179 for _, a := range tt.absent {
180 assert.NotContains(t, out, a)
181 }
182 for _, p := range tt.present {
183 assert.Contains(t, out, p)
184 }
185 })
186 }
187}
188
189func TestMarkdownUntrustedBigInputs(t *testing.T) {
190 t.Parallel()
191 long := strings.Repeat("a", 10000)
192 out := renderUntrustedMD(t, long)
193 assert.Contains(t, out, long)
194
195 rows := "| a | b |\n|---|---|\n" + strings.Repeat("| <b>x</b> | [l](javascript:1) |\n", 500)
196 out = renderUntrustedMD(t, rows)
197 assert.NotContains(t, out, "<b>")
198 assert.NotContains(t, out, "javascript")
199 assert.NotContains(t, out, "style=")
200
201 deep := strings.Repeat("> ", 200) + "[x](javascript:1)\n"
202 assert.NotContains(t, renderUntrustedMD(t, deep), "javascript")
203
204 assert.NotContains(t, renderUntrustedMD(t, "[x](https://a.test/\"onmouseover=\"alert(1))\n"), "onmouseover=\"")
205}
206
207func TestNoteMarkdown(t *testing.T) {
208 t.Parallel()
209 tests := []struct {
210 source string
211 links int
212 }{
213 {"^2 ^99 a^2 _^2 ^^2 1^2", 1},
214 {"`^2` [^2](https://example.org) [^2](javascript:alert(1))", 0},
215 {"```\n^2\n```", 0},
216 {"^2, ^2\n^2", 3},
217 }
218 for _, tt := range tests {
219 t.Run(tt.source, func(t *testing.T) {
220 t.Parallel()
221 h, err := newMarkdown().renderNote([]byte(tt.source), "demo", "main", "c1-", func(n int64) bool { return n == 2 })
222 require.NoError(t, err)
223 assert.Equal(t, tt.links, strings.Count(string(h), `href="#c2"`), string(h))
224 assert.NotContains(t, string(h), `href="#c99"`)
225 assert.NotContains(t, string(h), "javascript:")
226 })
227 }
228 h, err := newMarkdown().renderNote([]byte("# Heading\n\n<script>alert(1)</script>\n\n**bold** ^2"), "demo", "main", "c1-", func(n int64) bool { return n == 2 })
229 require.NoError(t, err)
230 assert.Contains(t, string(h), `id="c1-heading"`)
231 assert.Contains(t, string(h), "<strong>bold</strong>")
232 assert.NotContains(t, string(h), "<script")
233 assert.Contains(t, string(h), `title="note 2"`)
234}