internal/httpd/orgrender_test.go

e3632a550366fe23b2619edba30fc58fab19b598
gitbay/internal/httpd/orgrender_test.go history · blame · raw

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}