1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package web
4
5import (
6 "strings"
7 "testing"
8
9 "bnema.dev/ermite/internal/domain"
10 "bnema.dev/ermite/internal/ports/mocks"
11 "github.com/stretchr/testify/assert"
12 "github.com/stretchr/testify/mock"
13 "github.com/stretchr/testify/require"
14)
15
16func renderMD(t *testing.T, src string) (string, []Heading) {
17 t.Helper()
18 h, outline, err := newMarkdown().render([]byte(src), "demo", "main", "docs")
19 require.NoError(t, err)
20 return string(h), outline
21}
22
23// AC4: raw HTML, javascript: links and SVG are neutralised.
24func TestMarkdownNeutralisation(t *testing.T) {
25 t.Parallel()
26 tests := []struct {
27 name, src string
28 absent []string
29 present []string
30 }{
31 {
32 "raw html block", "<script>alert(1)</script>\n\n<div onclick=\"x()\">hi</div>\n",
33 []string{"<script", "<div", "onclick", "alert(1)</"}, nil,
34 },
35 {
36 "raw inline html", "a <img src=x onerror=alert(1)> b <b>c</b> <iframe src=//evil></iframe>\n",
37 []string{"<img", "onerror", "<iframe", "<b>"}, []string{"<p>a "},
38 },
39 {
40 "javascript link", "[x](javascript:alert(1)) [y](JaVaScRiPt:alert(1)) [z](java\tscript:alert(1))\n",
41 []string{"javascript", "JaVaScRiPt", "href=\"java"}, []string{"<p>x y "},
42 },
43 {
44 "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",
45 []string{"href=\"data", "vbscript", "file:", "mailto", "evil.example", "ftp:"}, []string{"<p>a b c d e f</p>"},
46 },
47 {
48 "autolinks", "<javascript:alert(1)> <https://ok.example/a> <me@example.org>\n",
49 []string{"href=\"javascript", "mailto:"}, []string{`href="https://ok.example/a"`, "me@example.org"},
50 },
51 {
52 "svg and images", "  \n\n<svg onload=alert(1)><circle/></svg>\n",
53 []string{"<img", "<svg", "onload", "evil.example", "base64", "src="}, []string{`<span class="alt">[logo]</span>`, `<span class="alt">[remote]</span>`, `<span class="alt">[image]</span>`},
54 },
55 {
56 "escaped text", "# <b>x</b> & \"q\"\n\nplain <u>tags</u> & \"quotes\"\n",
57 []string{"<b>", "<u>"}, []string{"&", """},
58 },
59 {
60 "title injection", "[x](https://a.example \"t\\\" onmouseover=\\\"alert(1)\")\n",
61 []string{"onmouseover=\"", " title="}, []string{`href="https://a.example"`},
62 },
63 }
64 for _, tt := range tests {
65 t.Run(tt.name, func(t *testing.T) {
66 t.Parallel()
67 out, _ := renderMD(t, tt.src)
68 for _, s := range tt.absent {
69 assert.NotContains(t, out, s)
70 }
71 for _, s := range tt.present {
72 assert.Contains(t, out, s)
73 }
74 // Whatever the input: no inline script, no event handler, no
75 // style, no element that loads something.
76 for _, banned := range []string{"<script", "<style", "<svg", "<img", "<iframe", "<object", "<embed", "<form", " on", "style="} {
77 if banned == " on" {
78 assert.NotRegexp(t, ` on[a-z]+=`, out)
79 continue
80 }
81 assert.NotContains(t, strings.ToLower(out), banned)
82 }
83 })
84 }
85}
86
87func TestMarkdownLinks(t *testing.T) {
88 t.Parallel()
89 tests := []struct{ name, src, href string }{
90 {"relative from the file's directory", "[a](other.md)", `href="/demo/blob/main/docs/other.md"`},
91 {"parent", "[a](../LICENSE)", `href="/demo/blob/main/LICENSE"`},
92 {"cannot climb out of the repository", "[a](../../../../etc/passwd)", `href="/demo/blob/main/etc/passwd"`},
93 {"root relative", "[a](/src/main.go)", `href="/demo/blob/main/src/main.go"`},
94 {"fragment on a file", "[a](x.md#part)", `href="/demo/blob/main/docs/x.md#part"`},
95 {"dot is the repository", "[a](../)", `href="/demo"`},
96 {"escaped segments", "[a](a%20b/c%3Fd.md)", `href="/demo/blob/main/docs/a%20b/c%3Fd.md"`},
97 {"https is external", "[a](https://example.org/p?q=1)", `href="https://example.org/p?q=1" rel="nofollow noopener noreferrer"`},
98 {"http too", "[a](http://example.org/)", `rel="nofollow noopener noreferrer"`},
99 {"heading anchor", "[a](#Install Now)", ``},
100 {"heading anchor ok", "[a](#install)", `href="#md-install"`},
101 }
102 for _, tt := range tests {
103 t.Run(tt.name, func(t *testing.T) {
104 t.Parallel()
105 out, _ := renderMD(t, tt.src)
106 if tt.href == "" {
107 assert.NotContains(t, out, "<a ")
108 return
109 }
110 assert.Contains(t, out, tt.href)
111 })
112 }
113}
114
115func TestMarkdownOutline(t *testing.T) {
116 t.Parallel()
117 out, outline := renderMD(t, "# Title\n\n## Install `now`\n\n### Install `now`\n\ntext\n\n#### raw *em*\n")
118 assert.Equal(t, []Heading{
119 {Level: 1, ID: "md-title", Text: "Title"},
120 {Level: 2, ID: "md-install-now", Text: "Install now"},
121 {Level: 3, ID: "md-install-now-1", Text: "Install now"},
122 {Level: 4, ID: "md-raw-em", Text: "raw em"},
123 }, outline, "ids are prefixed and unique, text has no markup")
124 for _, h := range outline {
125 assert.Contains(t, out, `id="`+h.ID+`"`)
126 }
127 // A heading cannot take the id of a page element.
128 out, outline = renderMD(t, "# main\n\n# keys\n")
129 assert.Equal(t, "md-main", outline[0].ID)
130 assert.NotContains(t, out, `id="main"`)
131 assert.NotContains(t, out, `id="keys"`)
132}
133
134func TestMarkdownGFM(t *testing.T) {
135 t.Parallel()
136 out, _ := renderMD(t, "| a | b |\n|:-|-:|\n| 1 | 2 |\n\n~~gone~~\n\n- [x] task\n")
137 assert.Contains(t, out, `<th align="right">b</th>`, "alignment is an attribute, never a style")
138 assert.NotContains(t, out, "style=")
139 assert.Contains(t, out, "<del>gone</del>")
140 assert.Contains(t, out, `type="checkbox"`)
141}
142
143func renderUntrustedMD(t *testing.T, src string) string {
144 t.Helper()
145 h, err := newMarkdown().renderUntrusted([]byte(src), "issue-desc-", nil)
146 require.NoError(t, err)
147 return string(h)
148}
149
150// An issue from the public form is rendered with the strictest policy.
151func TestMarkdownUntrusted(t *testing.T) {
152 t.Parallel()
153 tests := []struct {
154 name, src string
155 absent []string
156 present []string
157 }{
158 {"script", "<script>alert(1)</script>\n", []string{"<script", "alert(1)</"}, nil},
159 {"img onerror", "a <img src=x onerror=alert(1)> b\n", []string{"<img", "onerror"}, []string{"<p>a "}},
160 {"javascript link", "[x](javascript:alert(1))\n", []string{"javascript", "href"}, []string{"<p>x</p>"}},
161 {"data link", "[x](data:text/html,<script>alert(1)</script>)\n", []string{"data:", "href", "<script"}, nil},
162 {"vbscript and file", "[a](vbscript:x) [b](file:///etc/passwd)\n", []string{"vbscript", "file:", "href"}, []string{"a b"}},
163 {"relative links lose the link", "[a](../../etc) [b](/proj/blob/main/x) [c](#frag)\n", []string{"href"}, []string{"a b c"}},
164 {"protocol relative", "[x](//evil.test/p)\n", []string{"href", "evil"}, nil},
165 {"https is kept with ugc", "[x](https://example.org/p?q=1)\n", nil,
166 []string{`href="https://example.org/p?q=1"`, `rel="nofollow noopener noreferrer ugc"`}},
167 {"http is kept", "[x](http://example.org/)\n", nil, []string{`href="http://example.org/"`, "ugc"}},
168 {"credentials are refused", "[x](https://user:pw@example.org/)\n", []string{"href", "pw"}, nil},
169 {"mailto is kept", "[x](mailto:a@example.org)\n", nil, []string{`href="mailto:a@example.org"`, "ugc"}},
170 {"mailto with query is refused", "[x](mailto:a@example.org?cc=b@example.org)\n", []string{"href"}, nil},
171 {"autolink url", "<https://example.org/a> and <javascript:alert(1)>\n", []string{"href=\"javascript"}, []string{`rel="nofollow noopener noreferrer ugc"`}},
172 {"autolink email", "<a@example.org>\n", nil, []string{`href="mailto:a@example.org"`, "ugc"}},
173 {"bare url (GFM)", "see https://example.org/x now\n", nil, []string{`href="https://example.org/x"`, "ugc"}},
174 {"image is its alt text", "\n", []string{"<img", "evil.test"}, []string{`<span class="alt">[pwn]</span>`}},
175 {"reference link to javascript", "[x][r]\n\n[r]: javascript:alert(1)\n", []string{"javascript", "href"}, nil},
176 {"entity-encoded scheme", "[x](javascript:alert(1))\n", []string{"avascript:", "href"}, nil},
177 }
178 for _, tt := range tests {
179 t.Run(tt.name, func(t *testing.T) {
180 t.Parallel()
181 out := renderUntrustedMD(t, tt.src)
182 for _, a := range tt.absent {
183 assert.NotContains(t, out, a)
184 }
185 for _, p := range tt.present {
186 assert.Contains(t, out, p)
187 }
188 })
189 }
190}
191
192func TestMarkdownUntrustedBigInputs(t *testing.T) {
193 t.Parallel()
194 long := strings.Repeat("a", 10000)
195 out := renderUntrustedMD(t, long)
196 assert.Contains(t, out, long)
197
198 rows := "| a | b |\n|---|---|\n" + strings.Repeat("| <b>x</b> | [l](javascript:1) |\n", 500)
199 out = renderUntrustedMD(t, rows)
200 assert.NotContains(t, out, "<b>")
201 assert.NotContains(t, out, "javascript")
202 assert.NotContains(t, out, "style=")
203
204 deep := strings.Repeat("> ", 200) + "[x](javascript:1)\n"
205 assert.NotContains(t, renderUntrustedMD(t, deep), "javascript")
206
207 assert.NotContains(t, renderUntrustedMD(t, "[x](https://a.test/\"onmouseover=\"alert(1))\n"), "onmouseover=\"")
208}
209
210func TestNoteMarkdown(t *testing.T) {
211 t.Parallel()
212 tests := []struct {
213 source string
214 links int
215 }{
216 {"^2 ^99 a^2 _^2 ^^2 1^2", 1},
217 {"`^2` [^2](https://example.org) [^2](javascript:alert(1))", 0},
218 {"```\n^2\n```", 0},
219 {"^2, ^2\n^2", 3},
220 }
221 for _, tt := range tests {
222 t.Run(tt.source, func(t *testing.T) {
223 t.Parallel()
224 h, err := newMarkdown().renderNote([]byte(tt.source), "demo", "main", "c1-", refResolver{notes: func(n int64) bool { return n == 2 }})
225 require.NoError(t, err)
226 assert.Equal(t, tt.links, strings.Count(string(h), `href="#c2"`), string(h))
227 assert.NotContains(t, string(h), `href="#c99"`)
228 assert.NotContains(t, string(h), "javascript:")
229 })
230 }
231 h, err := newMarkdown().renderNote([]byte("# Heading\n\n<script>alert(1)</script>\n\n**bold** ^2"), "demo", "main", "c1-", refResolver{notes: func(n int64) bool { return n == 2 }})
232 require.NoError(t, err)
233 assert.Contains(t, string(h), `id="c1-heading"`)
234 assert.Contains(t, string(h), "<strong>bold</strong>")
235 assert.NotContains(t, string(h), "<script")
236 assert.Contains(t, string(h), `title="note 2"`)
237}
238
239func TestNumberReferences(t *testing.T) {
240 t.Parallel()
241 numbers := func(n int64) (string, domain.NumberKind, bool) {
242 switch n {
243 case 3:
244 return "/demo/issues/3", domain.NumberIssue, true
245 case 4:
246 return "/demo/merge-requests/4", domain.NumberMergeRequest, true
247 }
248 return "", domain.NumberNone, false
249 }
250 tests := []struct {
251 source string
252 want []string
253 not []string
254 }{
255 {"fix #3 and #4, not #9", []string{`<a href="/demo/issues/3" title="issue 3" class="ref">#3</a>`, `title="merge request 4"`}, []string{"/issues/9", "#9</a>"}},
256 {"abc#3 _#3 ##3 1#3", nil, []string{"<a "}},
257 {"' ", nil, []string{"<a "}},
258 {"`#3`\n\n```\n#3\n```", nil, []string{"<a "}},
259 {"[#3](https://example.org)", []string{`href="https://example.org"`}, []string{"/demo/issues/3"}},
260 {"#3\n#3", []string{`href="/demo/issues/3"`}, nil},
261 {"#03 #12345678901", nil, []string{"<a "}},
262 }
263 for _, tt := range tests {
264 t.Run(tt.source, func(t *testing.T) {
265 t.Parallel()
266 h, err := newMarkdown().renderIssue([]byte(tt.source), "demo", "main", "i-", numbers)
267 require.NoError(t, err)
268 for _, w := range tt.want {
269 assert.Contains(t, string(h), w)
270 }
271 for _, w := range tt.not {
272 assert.NotContains(t, string(h), w)
273 }
274 u, err := newMarkdown().renderUntrusted([]byte(tt.source), "i-", numbers)
275 require.NoError(t, err)
276 assert.Equal(t, strings.Count(string(h), `class="ref"`), strings.Count(string(u), `class="ref"`))
277 })
278 }
279 // Without a resolver nothing links.
280 h, err := newMarkdown().renderUntrusted([]byte("#3"), "i-", nil)
281 require.NoError(t, err)
282 assert.NotContains(t, string(h), "<a ")
283}
284
285func TestNumberAndNoteReferencesTogether(t *testing.T) {
286 t.Parallel()
287 h, err := newMarkdown().renderNote([]byte("see ^2 and #3."), "demo", "main", "c1-", refResolver{
288 notes: func(n int64) bool { return n == 2 },
289 numbers: func(n int64) (string, domain.NumberKind, bool) { return "/demo/issues/3", domain.NumberIssue, n == 3 },
290 })
291 require.NoError(t, err)
292 assert.Contains(t, string(h), `href="#c2"`)
293 assert.Contains(t, string(h), `href="/demo/issues/3"`)
294 assert.Contains(t, string(h), "and ")
295}
296
297func TestNumberResolverCapsLookups(t *testing.T) {
298 t.Parallel()
299 nums := mocks.NewIssueNumberResolver(t)
300 nums.EXPECT().Kind(mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(domain.NumberIssue, nil).Times(maxNumberLookups)
301 srv := &Server{cfg: Config{IssueNumbers: nums}}
302 resolve := srv.numberResolver(t.Context(), "demo")
303 require.NotNil(t, resolve)
304 for n := int64(1); n <= maxNumberLookups+10; n++ {
305 _, _, ok := resolve(n)
306 assert.Equal(t, n <= maxNumberLookups, ok, n)
307 }
308 _, _, ok := resolve(1) // memoized
309 assert.True(t, ok)
310}
311
312func TestNumberReferenceTitleFromKindNotHref(t *testing.T) {
313 t.Parallel()
314 numbers := func(n int64) (string, domain.NumberKind, bool) {
315 return "/owner/merge-requests/issues/3", domain.NumberIssue, true
316 }
317 h, err := newMarkdown().renderIssue([]byte("see #3"), "merge-requests", "main", "i-", numbers)
318 require.NoError(t, err)
319 assert.Contains(t, string(h), `title="issue 3"`)
320 assert.NotContains(t, string(h), "merge request 3")
321}
322
323func TestMarkdownFencedHighlight(t *testing.T) {
324 tests := []struct {
325 name, info, code string
326 highlighted bool
327 }{
328 {"go", "go", "package main\nfunc main() {}\n", true},
329 {"info suffix", "go example", "package main\n", true},
330 {"unknown", "not-a-real-language", "<script>\n", false},
331 {"empty", "", "<script>\n", false},
332 {"html", "html", "<script>alert(1)</script>\n", true},
333 {"injection", `go" onmouseover=x`, "package main\n", false},
334 {"oversize", "go", strings.Repeat("x", markdownBlockBytes+1) + "\n", false},
335 }
336 for _, tt := range tests {
337 t.Run(tt.name, func(t *testing.T) {
338 src := "```" + tt.info + "\n" + tt.code + "```\n"
339 for _, untrusted := range []bool{false, true} {
340 var h string
341 if untrusted {
342 out, err := newMarkdown().renderUntrusted([]byte(src), "test-", nil)
343 require.NoError(t, err)
344 h = string(out)
345 } else {
346 h, _ = renderMD(t, src)
347 }
348 assert.Equal(t, tt.highlighted, strings.Contains(h, `<pre class="chroma">`))
349 assert.NotContains(t, h, "<script>")
350 assert.NotContains(t, h, `style=`)
351 assert.NotContains(t, h, `onmouseover`)
352 if tt.name == "go" {
353 assert.Contains(t, h, `<span class="kn">package</span>`)
354 }
355 if tt.name == "html" {
356 assert.Contains(t, h, "<")
357 assert.Contains(t, h, "script")
358 }
359 if !tt.highlighted {
360 assert.NotContains(t, h, "<span")
361 assert.Contains(t, h, "<pre><code")
362 }
363 }
364 })
365 }
366}
367
368func TestMarkdownHighlightMarkers(t *testing.T) {
369 h, _ := renderMD(t, "```go\n// invisible \u202e\n```\n")
370 assert.Contains(t, h, `class="ctl"`)
371 assert.Contains(t, h, "U+202E")
372 assert.NotContains(t, h, "\u202e")
373}
374
375func TestMarkdownHighlightDocumentBudget(t *testing.T) {
376 // Even thousands of tiny blocks may only attempt 128 lexes per document.
377 h, _ := renderMD(t, strings.Repeat("```go\npackage main\n```\n\n", 1000))
378 assert.LessOrEqual(t, strings.Count(h, `<pre class="chroma">`), 128)
379 assert.Contains(t, h, "<pre><code")
380 // The byte budget also stops blocks before the block-count budget.
381 block := "```go\n//" + strings.Repeat("x", markdownBlockBytes-4) + "\n```\n\n"
382 h, _ = renderMD(t, strings.Repeat(block, 6))
383 assert.LessOrEqual(t, strings.Count(h, `<pre class="chroma">`), 4)
384 assert.Contains(t, h, "<pre><code")
385}
386
387func TestMarkdownVideos(t *testing.T) {
388 for _, tc := range []struct {
389 name, source, want string
390 video bool
391 }{
392 {"relative", ``, `src="/demo/raw/main/docs/clips/demo.mp4" aria-label="demo"`, true},
393 {"root mov", ``, `src="/demo/raw/main/clip.MOV"`, true},
394 {"webm", ``, `src="/demo/raw/main/docs/clip.webm"`, true},
395 {"m4v", ``, `src="/demo/raw/main/docs/clip.m4v"`, true},
396 {"ogv", ``, `src="/demo/raw/main/docs/clip.ogv"`, true},
397 {"external image", ``, `src="https://github.com/demo.mp4"`, true},
398 {"autolink", `https://github.com/user-attachments/assets/uuid`, `src="https://github.com/user-attachments/assets/uuid"`, true},
399 {"angle autolink", `<https://github.com/user-attachments/assets/uuid>`, `src="https://github.com/user-attachments/assets/uuid"`, true},
400 {"standalone link", `[clip](https://github.com/user-attachments/assets/uuid)`, `aria-label="clip"`, true},
401 {"repo link", `[Source](https://github.com/org/project)`, `<a href="https://github.com/org/project"`, false},
402 {"issue link", `[Issue](https://github.com/org/project/issues/123)`, `<a href="https://github.com/org/project/issues/123"`, false},
403 {"root autolink", `https://github.com`, `<a href="https://github.com"`, false},
404 {"root link", `[GitHub](https://github.com/)`, `<a href="https://github.com/"`, false},
405 {"standalone video extension", `[clip](https://user-images.githubusercontent.com/demo.MP4?download=1)`, `<video controls`, true},
406 {"video autolink", `https://github.com/demo.webm`, `<video controls`, true},
407 {"attachment missing id", `https://github.com/user-attachments/assets/`, `<a href=`, false},
408 {"attachment extra segment", `https://github.com/user-attachments/assets/id/extra`, `<a href=`, false},
409 {"attachment nonvideo extension", `https://github.com/user-attachments/assets/id.png`, `<a href=`, false},
410 {"attachment other host", `https://user-images.githubusercontent.com/user-attachments/assets/id`, `<a href=`, false},
411 {"inline link", `see [clip](https://github.com/user-attachments/assets/uuid) here`, `<a href=`, false},
412 {"blocked image", ``, `<span class="alt">[demo]</span>`, false},
413 {"blocked link", `https://evil.example/demo.mp4`, `<a href=`, false},
414 {"host suffix", ``, `<span class="alt">`, false},
415 {"credentials", ``, `<span class="alt">`, false},
416 {"protocol relative", ``, `<span class="alt">`, false},
417 {"javascript", ``, `<span class="alt">`, false},
418 {"ordinary image", ``, `<span class="alt">`, false},
419 {"escaped alt", ``, `aria-label="a " & \<b\>"`, true},
420 {"escaped path", ``, `src="/demo/raw/main/docs/a%22&b.mp4"`, true},
421 } {
422 t.Run(tc.name, func(t *testing.T) {
423 h, _ := renderMD(t, tc.source)
424 assert.Contains(t, h, tc.want)
425 assert.Equal(t, tc.video, strings.Contains(h, `<video controls preload="metadata"`))
426 untrusted, err := newMarkdown().renderUntrusted([]byte(tc.source), "u-", nil)
427 require.NoError(t, err)
428 assert.NotContains(t, string(untrusted), "<video")
429 })
430 }
431 t.Run("configured host", func(t *testing.T) {
432 m := newMarkdown("clips.example")
433 h, _, err := m.render([]byte(``), "demo", "main", "")
434 require.NoError(t, err)
435 assert.Contains(t, string(h), "<video")
436 h, _, err = m.render([]byte(``), "demo", "main", "")
437 require.NoError(t, err)
438 assert.NotContains(t, string(h), "<video")
439 })
440}