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