internal/httpd/orgrender_test.go

v1.36.0
gitbay/internal/httpd/orgrender_test.go history · blame · raw

193 lines · 7517 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 tabindex="0" 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}