v0.35.0 e86dbd6

markdown_test.go RawHistoryFolder

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", "![logo](x.svg) ![remote](https://evil.example/p.png) ![](data:image/svg+xml;base64,PHN2Zz4=)\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{"&amp;", "&quot;"},
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", "![pwn](https://evil.test/p.png)\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](&#106;avascript: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		{"&#39; &#3;", 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, "&lt;")
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}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 4

Tags 45

↑↓ moveenter openesc close