Commit b13a24703d

b13a24703d27dd4da6ceed2ba4b87c5fd4fffc61

parent: ed99c936ad

Verified · cmc

cmc <hello@cleberg.net> · 2026-09-28 08:43 UTC

config: server.secret_key_file, outside server.root

Ref #273

Layout: unified · split

internal/config/config.go +54 −1
@@ -9,6 +9,7 @@ import (
9 "fmt" 9 "fmt"
10 "net" 10 "net"
11 "os" 11 "os"
12 "path/filepath"
12 "strconv" 13 "strconv"
13 "strings" 14 "strings"
14 "time" 15 "time"
@@ -54,6 +55,11 @@ type Server struct {
54 // not on that repository's default branch. Empty disables the check, which 55 // not on that repository's default branch. Empty disables the check, which
55 // is right for any instance that does not host its own source. 56 // is right for any instance that does not host its own source.
56 SourceRepo string `toml:"source_repo"` 57 SourceRepo string `toml:"source_repo"`
58
59 // SecretKeyFile holds the keys that seal the secret columns of the
60 // database (internal/seal). It lives outside Root, so neither a
61 // backup archive nor a snapshot of Root carries it.
62 SecretKeyFile string `toml:"secret_key_file"`
57} 63}
58 64
59type SSH struct { 65type SSH struct {
@@ -294,7 +300,7 @@ func LoadAPNSKey(path string) (*ecdsa.PrivateKey, error) {
294// Default returns the configuration used when a key is absent from the file. 300// Default returns the configuration used when a key is absent from the file.
295func Default() Config { 301func Default() Config {
296 return Config{ 302 return Config{
297 Server: Server{Root: "/var/lib/gitbay"}, 303 Server: Server{Root: "/var/lib/gitbay", SecretKeyFile: "/etc/gitbay/secret.key"},
298 SSH: SSH{Mode: "embedded", Port: 22}, 304 SSH: SSH{Mode: "embedded", Port: 22},
299 HTTP: HTTP{Addr: ":443", TLS: "acme", ACMEHTTPAddr: ":80"}, 305 HTTP: HTTP{Addr: ":443", TLS: "acme", ACMEHTTPAddr: ":80"},
300 Web: Web{Mode: "view_only"}, 306 Web: Web{Mode: "view_only"},
@@ -330,6 +336,47 @@ func Load(path string) (Config, error) {
330 return cfg, cfg.Validate() 336 return cfg, cfg.Validate()
331} 337}
332 338
339// within reports whether path is dir or below it. Both are compared as
340// cleaned absolute paths (a relative path resolves against the working
341// directory, same as every other path in this config), with symlinks
342// resolved where the path exists on disk, so a path that reaches into dir
343// through a symlink, or through "..", is still reported as inside.
344func within(dir, path string) bool {
345 dir, path = resolvePath(dir), resolvePath(path)
346 rel, err := filepath.Rel(dir, path)
347 return err == nil && rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator))
348}
349
350// resolvePath returns path as a cleaned absolute path, resolving symlinks in
351// it. The secret key file commonly does not exist yet (it is created by
352// `gitbayd admin secrets init`), and on this platform /var itself is a
353// symlink, so a whole-path resolution is tried first and, failing that, each
354// ancestor directory in turn, walking up to the nearest one that exists and
355// reattaching the missing suffix — a symlinked ancestor still resolves even
356// though the leaf, or several levels above it, does not exist.
357func resolvePath(path string) string {
358 abs, err := filepath.Abs(path)
359 if err != nil {
360 return filepath.Clean(path)
361 }
362 dir := abs
363 var suffix []string
364 for {
365 if resolved, err := filepath.EvalSymlinks(dir); err == nil {
366 for i := len(suffix) - 1; i >= 0; i-- {
367 resolved = filepath.Join(resolved, suffix[i])
368 }
369 return resolved
370 }
371 parent := filepath.Dir(dir)
372 if parent == dir {
373 return abs
374 }
375 suffix = append(suffix, filepath.Base(dir))
376 dir = parent
377 }
378}
379
333func oneOf(field, val string, allowed ...string) error { 380func oneOf(field, val string, allowed ...string) error {
334 for _, a := range allowed { 381 for _, a := range allowed {
335 if val == a { 382 if val == a {
@@ -357,6 +404,12 @@ func (c Config) Validate() error {
357 if c.Server.SiteURL == "" { 404 if c.Server.SiteURL == "" {
358 errs = append(errs, errors.New("server.site_url is required")) 405 errs = append(errs, errors.New("server.site_url is required"))
359 } 406 }
407 switch {
408 case c.Server.SecretKeyFile == "":
409 errs = append(errs, errors.New("server.secret_key_file is required"))
410 case within(c.Server.Root, c.Server.SecretKeyFile):
411 errs = append(errs, fmt.Errorf("server.secret_key_file %q is inside server.root: backups of the root would carry the key beside the values it seals", c.Server.SecretKeyFile))
412 }
360 if err := oneOf("ssh.mode", c.SSH.Mode, "embedded", "system"); err != nil { 413 if err := oneOf("ssh.mode", c.SSH.Mode, "embedded", "system"); err != nil {
361 errs = append(errs, err) 414 errs = append(errs, err)
362 } 415 }
internal/config/config_test.go +91
@@ -275,3 +275,94 @@ func TestMailTLSRequired(t *testing.T) {
275 } 275 }
276 } 276 }
277} 277}
278
279func TestSecretKeyFile(t *testing.T) {
280 cfg, err := Load(writeConfig(t, minimal))
281 if err != nil {
282 t.Fatal(err)
283 }
284 if cfg.Server.SecretKeyFile != "/etc/gitbay/secret.key" {
285 t.Errorf("default secret_key_file = %q", cfg.Server.SecretKeyFile)
286 }
287 for body, want := range map[string]string{
288 minimal + "secret_key_file = \"/var/lib/gitbay/secret.key\"\n": "inside server.root",
289 minimal + "secret_key_file = \"/var/lib/gitbay\"\n": "inside server.root",
290 minimal + "secret_key_file = \"\"\n": "server.secret_key_file is required",
291 } {
292 if _, err := Load(writeConfig(t, body)); err == nil || !strings.Contains(err.Error(), want) {
293 t.Errorf("%q: got %v, want an error containing %q", body, err, want)
294 }
295 }
296 if _, err := Load(writeConfig(t, minimal+"secret_key_file = \"/var/lib/gitbay-keys/secret.key\"\n")); err != nil {
297 t.Errorf("a sibling directory of the root is outside it: %v", err)
298 }
299}
300
301// TestSecretKeyFileSymlinks exercises resolvePath's symlink resolution: a
302// key path or root reached through a symlink is still compared on its
303// resolved location, not its literal spelling.
304func TestSecretKeyFileSymlinks(t *testing.T) {
305 valid := func(root, keyFile string) Config {
306 cfg := Default()
307 cfg.Server.SiteURL = "https://gitbay.example"
308 cfg.Server.Root = root
309 cfg.Server.SecretKeyFile = keyFile
310 return cfg
311 }
312
313 t.Run("key path reaches into root through a symlink", func(t *testing.T) {
314 tmp := t.TempDir()
315 root := filepath.Join(tmp, "root")
316 if err := os.Mkdir(root, 0o700); err != nil {
317 t.Fatal(err)
318 }
319 link := filepath.Join(tmp, "link-into-root")
320 if err := os.Symlink(root, link); err != nil {
321 t.Fatal(err)
322 }
323 // The key file itself need not exist yet; only the symlinked
324 // directory component does.
325 keyFile := filepath.Join(link, "secret.key")
326 if err := valid(root, keyFile).Validate(); err == nil || !strings.Contains(err.Error(), "inside server.root") {
327 t.Errorf("got %v, want an error containing %q", err, "inside server.root")
328 }
329 })
330
331 t.Run("root itself is reached through a symlinked parent", func(t *testing.T) {
332 tmp := t.TempDir()
333 actualRoot := filepath.Join(tmp, "actual", "root")
334 if err := os.MkdirAll(actualRoot, 0o700); err != nil {
335 t.Fatal(err)
336 }
337 rootLink := filepath.Join(tmp, "root-link")
338 if err := os.Symlink(actualRoot, rootLink); err != nil {
339 t.Fatal(err)
340 }
341 // server.root is configured as the symlink; the key file is given
342 // by its real, unsymlinked path under the same directory.
343 keyFile := filepath.Join(actualRoot, "secret.key")
344 if err := valid(rootLink, keyFile).Validate(); err == nil || !strings.Contains(err.Error(), "inside server.root") {
345 t.Errorf("got %v, want an error containing %q", err, "inside server.root")
346 }
347 })
348
349 t.Run("symlink points outside root", func(t *testing.T) {
350 tmp := t.TempDir()
351 root := filepath.Join(tmp, "root")
352 outside := filepath.Join(tmp, "outside")
353 if err := os.Mkdir(root, 0o700); err != nil {
354 t.Fatal(err)
355 }
356 if err := os.Mkdir(outside, 0o700); err != nil {
357 t.Fatal(err)
358 }
359 escape := filepath.Join(root, "escape")
360 if err := os.Symlink(outside, escape); err != nil {
361 t.Fatal(err)
362 }
363 keyFile := filepath.Join(escape, "secret.key")
364 if err := valid(root, keyFile).Validate(); err != nil {
365 t.Errorf("a symlink leading outside server.root should be accepted: %v", err)
366 }
367 })
368}