internal/httpd/orgrender_test.go
193 lines · 7504 bytes
1package httpd
2
3import (
4 "html/template"
5 "os"
6 "path/filepath"
7 "strings"
8 "testing"
9)
10
11// Org is rendered by go-org, whose default configuration reads #+INCLUDE: and
12// #+SETUPFILE: targets straight off disk. The content being rendered is not
13// trusted — a README or wiki page is whatever someone pushed — so those
14// keywords must never reach the filesystem.
15//
16// The leaking form is `#+INCLUDE: "<path>" src <lang>`: the file becomes a
17// source block, which survives the sanitizer as a chroma-highlighted <pre>.
18
19const orgSecret = "SENTINEL-SERVER-SIDE-SECRET"
20
21func secretFile(t *testing.T) string {
22 t.Helper()
23 path := filepath.Join(t.TempDir(), "secret.txt")
24 if err := os.WriteFile(path, []byte(orgSecret), 0o600); err != nil {
25 t.Fatalf("write secret: %v", err)
26 }
27 return path
28}
29
30func TestOrgIncludeDoesNotReadAbsolutePaths(t *testing.T) {
31 path := secretFile(t)
32 for _, kind := range []string{"src text", "example", "export html"} {
33 src := "#+INCLUDE: \"" + path + "\" " + kind + "\n"
34 out := string(renderReadme("README.org", []byte(src)))
35 if strings.Contains(out, orgSecret) {
36 t.Errorf("#+INCLUDE %q read a server file into the page:\n%s", kind, out)
37 }
38 }
39}
40
41// A relative include resolves against filepath.Dir(document path). renderReadme
42// passes a bare filename, so that directory is the daemon's working directory
43// and traversal reaches anything above it. go.mod is a stand-in for any file
44// the daemon can read but a reader should not see.
45func TestOrgIncludeDoesNotTraverseRelativePaths(t *testing.T) {
46 src := "#+INCLUDE: \"../../go.mod\" src text\n"
47
48 out := string(renderReadme("README.org", []byte(src)))
49
50 if strings.Contains(out, "module gitbay.org/gitbay") {
51 t.Fatalf("relative #+INCLUDE traversed out of the working directory:\n%s", out)
52 }
53}
54
55// The guard itself, asserted directly. #+SETUPFILE: reads at parse time and
56// folds the result into buffer settings rather than printing it, so a rendered
57// page is a weak place to observe that read; this is not.
58func TestOrgConfigRefusesToReadFiles(t *testing.T) {
59 path := secretFile(t)
60
61 if _, err := orgConfig().ReadFile(path); err == nil {
62 t.Fatal("orgConfig().ReadFile opened a file; #+INCLUDE and #+SETUPFILE must be refused")
63 }
64}
65
66// #+SETUPFILE: reads at parse time and folds the result into buffer settings.
67// It does not print the file, but it still reads it, and anything it defines —
68// a macro, say — becomes observable in the output.
69func TestOrgSetupFileDoesNotReadServerFiles(t *testing.T) {
70 path := filepath.Join(t.TempDir(), "setup.org")
71 if err := os.WriteFile(path, []byte("#+MACRO: leak "+orgSecret+"\n"), 0o600); err != nil {
72 t.Fatalf("write setup file: %v", err)
73 }
74 src := "#+SETUPFILE: " + path + "\n\n{{{leak}}}\n"
75
76 out := string(renderReadme("README.org", []byte(src)))
77
78 if strings.Contains(out, orgSecret) {
79 t.Fatalf("#+SETUPFILE read a server file:\n%s", out)
80 }
81}
82
83// The keyword itself is harmless text; only the file read is the problem. A
84// document that uses it should still render everything else.
85func TestOrgIncludeLeavesTheRestOfTheDocumentIntact(t *testing.T) {
86 src := "* Real Heading\n\n#+INCLUDE: \"/etc/passwd\" src text\n\nBody text.\n"
87
88 out := string(renderReadme("README.org", []byte(src)))
89
90 if !strings.Contains(out, "Real Heading") {
91 t.Errorf("heading missing from output:\n%s", out)
92 }
93 if !strings.Contains(out, "Body text.") {
94 t.Errorf("body missing from output:\n%s", out)
95 }
96 if strings.Contains(out, "root:") {
97 t.Errorf("include read /etc/passwd:\n%s", out)
98 }
99}
100
101// Ordinary org must keep rendering exactly as before.
102func TestOrgRenderingIsUnaffectedByTheIncludeGuard(t *testing.T) {
103 src := "* Heading\n\nSome /emphasis/ and =code=.\n\n#+BEGIN_SRC go\nfmt.Println(\"hi\")\n#+END_SRC\n"
104
105 out := string(renderReadme("README.org", []byte(src)))
106
107 // The source block is chroma-highlighted, so its text is split across spans;
108 // check the block and a token rather than the joined source line.
109 for _, want := range []string{"Heading", "<em>emphasis</em>", "<code>code</code>",
110 `<pre class="chroma">`, "Println"} {
111 if !strings.Contains(out, want) {
112 t.Errorf("expected %q in output:\n%s", want, out)
113 }
114 }
115}
116
117// Bodies — issues, MRs, comments, release notes — render in the format they
118// were written in. The format travels with the text, so anything stored before
119// formats existed still renders as markdown.
120
121func TestUGCHTMLRendersOrgWhenAsked(t *testing.T) {
122 out := string(ugcHTML("* Heading\n\nSome /emphasis/ and =code=.", "org"))
123
124 if !strings.Contains(out, "<em>emphasis</em>") || !strings.Contains(out, "<code>code</code>") {
125 t.Errorf("org body did not render as org:\n%s", out)
126 }
127 if strings.Contains(out, "* Heading") {
128 t.Errorf("org heading left as literal text:\n%s", out)
129 }
130}
131
132func TestUGCHTMLDefaultsToMarkdown(t *testing.T) {
133 // "" is what every row written before the format column existed carries.
134 for _, format := range []string{"", "md"} {
135 out := string(ugcHTML("A **bold** claim.", format))
136 if !strings.Contains(out, "<strong>bold</strong>") {
137 t.Errorf("format %q did not render as markdown:\n%s", format, out)
138 }
139 }
140}
141
142// An org body is not a document, so it should not grow a table of contents the
143// way a README does.
144func TestUGCHTMLOmitsTheTableOfContents(t *testing.T) {
145 body := "* First\n\ntext\n\n* Second\n\nmore\n"
146
147 // The heading anchors themselves are section ids; a link *to* one is the
148 // table of contents, which is what a body must not grow.
149 if out := string(ugcHTML(body, "org")); strings.Contains(out, `href="#headline-1"`) {
150 t.Errorf("body sprouted a table of contents:\n%s", out)
151 }
152 // A README still gets one.
153 if out := string(renderReadme("README.org", []byte(body))); !strings.Contains(out, `href="#headline-1"`) {
154 t.Errorf("README lost its table of contents:\n%s", out)
155 }
156}
157
158// Bodies are the lowest-trust org on the instance: repo content needs push
159// access, but anyone who can comment can write one. The include guard must
160// cover them.
161func TestUGCHTMLOrgCannotReadServerFiles(t *testing.T) {
162 path := secretFile(t)
163 body := "#+INCLUDE: \"" + path + "\" src text\n"
164
165 if out := string(ugcHTML(body, "org")); strings.Contains(out, orgSecret) {
166 t.Fatalf("an org body read a server file:\n%s", out)
167 }
168}
169
170// go-org's autolink parser takes every RFC 3986 character, trailing
171// punctuation included, so a bare URL at the end of a sentence or inside
172// parentheses swallowed the `).` after it. Org itself stops a plain link
173// before trailing punctuation and only keeps a `)` that closes a `(` inside
174// the link.
175func TestOrgAutolinkStopsBeforeTrailingPunctuation(t *testing.T) {
176 cases := []struct{ src, href, after string }{
177 {"fork of X (https://git.example/a/B). upstream", "https://git.example/a/B", "). upstream"},
178 {"see https://example.com.", "https://example.com", "."},
179 {"see https://example.com/q?x=1,", "https://example.com/q?x=1", ","},
180 {"see https://en.wikipedia.org/wiki/Foo_(bar) now", "https://en.wikipedia.org/wiki/Foo_(bar)", " now"},
181 {"(see https://en.wikipedia.org/wiki/Foo_(bar)).", "https://en.wikipedia.org/wiki/Foo_(bar)", ")."},
182 }
183 for _, c := range cases {
184 out := string(renderReadme("README.org", []byte(c.src+"\n")))
185 want := `href="` + c.href + `"`
186 if !strings.Contains(out, want) {
187 t.Errorf("%q: want %s in\n%s", c.src, want, out)
188 }
189 if !strings.Contains(out, "</a>"+template.HTMLEscapeString(c.after)) {
190 t.Errorf("%q: want %q after the link in\n%s", c.src, c.after, out)
191 }
192 }
193}