A CLI-first git forge.

cli forge git self-hosted

https://gitbay.org

Commit b8cda1cc1e

b8cda1cc1e8b3ec81120706e23e9fb8f9fbe28ad

parent: 808cf7cd34

Verified · cmc ci/build: success

cmc <hello@cleberg.net> · 2026-08-29T01:55:30Z

org: refuse #+INCLUDE and #+SETUPFILE when rendering

go-org's default configuration reads both keywords' targets off disk with
os.ReadFile. Everything renderReadme handles is content someone pushed — a
README, a wiki page, a profile's about text — so a pushed document could read
any file the daemon can open. "#+INCLUDE: \"/etc/passwd\" src text" rendered
the file into the page; the "export html" kind additionally injected it as raw
HTML rather than escaped text. An absolute path skipped go-org's relative-path
join, and a relative one resolved against the daemon's working directory, so
traversal reached anything above it.

Both keywords are now refused: the file is never opened and the keyword stays
inert text, leaving the rest of the document rendering unchanged. There is no
safe subset to allow instead — the content comes from a git object, so there is
no directory to scope a read to.

Also discard go-org's parse warnings, which the default logger wrote to stderr,
letting pushed content write to the server's log.

Ref #28. Ref #51 — this is the prerequisite that issue names before user-authored
bodies can be rendered as org.
internal/httpd/orgrender_test.go added +114
@@ -0,0 +1,114 @@
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}
internal/httpd/web.go +27 −1
@@ -2,9 +2,11 @@ package httpd
22
33 import (
44 "bytes"
5 "errors"
56 "fmt"
67 "hash/fnv"
78 "io"
9 "log"
810 "os"
911 "path/filepath"
1012
@@ -1095,6 +1097,30 @@ var ugcPolicy = func() *bluemonday.Policy {
10951097
10961098 // renderReadme renders a README by extension: markdown, org-mode, and
10971099 // (sanitized) HTML richly; everything else as escaped plaintext.
1100// orgConfig is the go-org configuration for rendering untrusted org.
1101//
1102// go-org's default reads #+INCLUDE: and #+SETUPFILE: targets off disk with
1103// os.ReadFile. Everything rendered here is content someone pushed — a README, a
1104// wiki page, a profile — so both keywords are refused outright: the file is
1105// never opened and the keyword stays the inert text it is. There is no safe
1106// subset to allow instead. An absolute path skips go-org's relative-path join,
1107// a relative one resolves against the daemon's working directory, and a repo
1108// has no directory to scope to anyway because the content came from a git
1109// object rather than a checkout.
1110//
1111// The default logger writes parse warnings to stderr, which would let pushed
1112// content write to the server's log; discard them.
1113func orgConfig() *org.Configuration {
1114 c := org.New()
1115 c.ReadFile = func(string) ([]byte, error) {
1116 return nil, errOrgIncludeDisabled
1117 }
1118 c.Log = log.New(io.Discard, "", 0)
1119 return c
1120}
1121
1122var errOrgIncludeDisabled = errors.New("org: #+INCLUDE and #+SETUPFILE are disabled")
1123
10981124 func renderReadme(name string, raw []byte) template.HTML {
10991125 plain := func() template.HTML {
11001126 return template.HTML("<pre>" + template.HTMLEscapeString(string(raw)) + "</pre>")
@@ -1110,7 +1136,7 @@ func renderReadme(name string, raw []byte) template.HTML {
11101136 }
11111137 return template.HTML(buf.String())
11121138 case ".org":
1113 doc := org.New().Parse(bytes.NewReader(raw), name)
1139 doc := orgConfig().Parse(bytes.NewReader(raw), name)
11141140 writer := org.NewHTMLWriter()
11151141 writer.HighlightCodeBlock = func(source, lang string, inline bool, params map[string]string) string {
11161142 if inline {