Commit b13a24703d
Verified · cmc
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 | ||
| 59 | type SSH struct { | 65 | type 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. |
| 295 | func Default() Config { | 301 | func 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. | ||
| 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 | func oneOf(field, val string, allowed ...string) error { | 380 | func 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 | |||
| 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 | } | ||