Commit b13a24703d
Verified · cmc
Layout: unified · split
internal/config/config.go +54 −1
| @@ -9,6 +9,7 @@ import ( | ||
| 9 | 9 | "fmt" |
| 10 | 10 | "net" |
| 11 | 11 | "os" |
| 12 | "path/filepath" | |
| 12 | 13 | "strconv" |
| 13 | 14 | "strings" |
| 14 | 15 | "time" |
| @@ -54,6 +55,11 @@ type Server struct { | ||
| 54 | 55 | // not on that repository's default branch. Empty disables the check, which |
| 55 | 56 | // is right for any instance that does not host its own source. |
| 56 | 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 | |
| 59 | 65 | type SSH struct { |
| @@ -294,7 +300,7 @@ func LoadAPNSKey(path string) (*ecdsa.PrivateKey, error) { | ||
| 294 | 300 | // Default returns the configuration used when a key is absent from the file. |
| 295 | 301 | func Default() Config { |
| 296 | 302 | return Config{ |
| 297 | Server: Server{Root: "/var/lib/gitbay"}, | |
| 303 | Server: Server{Root: "/var/lib/gitbay", SecretKeyFile: "/etc/gitbay/secret.key"}, | |
| 298 | 304 | SSH: SSH{Mode: "embedded", Port: 22}, |
| 299 | 305 | HTTP: HTTP{Addr: ":443", TLS: "acme", ACMEHTTPAddr: ":80"}, |
| 300 | 306 | Web: Web{Mode: "view_only"}, |
| @@ -330,6 +336,47 @@ func Load(path string) (Config, error) { | ||
| 330 | 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. | |
| 344 | func 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. | |
| 357 | func 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 | ||
| 333 | 380 | func oneOf(field, val string, allowed ...string) error { |
| 334 | 381 | for _, a := range allowed { |
| 335 | 382 | if val == a { |
| @@ -357,6 +404,12 @@ func (c Config) Validate() error { | ||
| 357 | 404 | if c.Server.SiteURL == "" { |
| 358 | 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 | 413 | if err := oneOf("ssh.mode", c.SSH.Mode, "embedded", "system"); err != nil { |
| 361 | 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 | ||
| 279 | func 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. | |
| 304 | func 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 | } | |