diff --git a/conf/configuration.go b/conf/configuration.go index 0b44f8f62..916efe70b 100644 --- a/conf/configuration.go +++ b/conf/configuration.go @@ -27,6 +27,7 @@ type configOptions struct { Address string Port int UnixSocketPerm string + EnforceNonRootUser bool MusicFolder string DataFolder string CacheFolder string @@ -273,6 +274,12 @@ var logFatal = func(args ...any) { os.Exit(1) } +var getEUID = os.Geteuid + +var currentGOOS = func() string { + return runtime.GOOS +} + var ( Server = &configOptions{} hooks []func() @@ -303,6 +310,11 @@ func Load(noConfigDump bool) { logFatal("Error parsing config:", err) } + // Validate non-root user early, before any filesystem operations + if err := validateEnforceNonRootUser(); err != nil { + logFatal(err) + } + err = os.MkdirAll(Server.DataFolder, os.ModePerm) if err != nil { logFatal("Error creating data path:", err) @@ -599,6 +611,18 @@ func validateMaxImageUploadSize() error { return nil } +func validateEnforceNonRootUser() error { + if !Server.EnforceNonRootUser || currentGOOS() == "windows" { + return nil + } + + if getEUID() == 0 { + return fmt.Errorf("EnforceNonRootUser is enabled but Navidrome is running as root") + } + + return nil +} + func validateScanSchedule() error { if Server.Scanner.Schedule == "0" || Server.Scanner.Schedule == "" { Server.Scanner.Schedule = "" @@ -698,6 +722,7 @@ func setViperDefaults() { viper.SetDefault("address", "0.0.0.0") viper.SetDefault("port", 4533) viper.SetDefault("unixsocketperm", "0660") + viper.SetDefault("enforcenonrootuser", false) viper.SetDefault("sessiontimeout", consts.DefaultSessionTimeout) viper.SetDefault("baseurl", "") viper.SetDefault("tlscert", "") diff --git a/conf/configuration_test.go b/conf/configuration_test.go index 121b1902c..5d4e73fad 100644 --- a/conf/configuration_test.go +++ b/conf/configuration_test.go @@ -250,6 +250,49 @@ var _ = Describe("Configuration", func() { ) }) + Describe("EnforceNonRootUser", func() { + It("defaults to false", func() { + conf.Load(true) + + Expect(conf.Server.EnforceNonRootUser).To(BeFalse()) + }) + + It("allows startup for non-root users when enabled", func() { + DeferCleanup(conf.SetRuntimeInfoForTest("linux", 1000)) + viper.Set("enforcenonrootuser", true) + + conf.Load(true) + + Expect(conf.Server.EnforceNonRootUser).To(BeTrue()) + }) + + It("exits when enabled and running as root without having created a data folder", func() { + // Create a path that doesn't exist yet + tempBase := GinkgoT().TempDir() + nonExistentDataFolder := filepath.Join(tempBase, "nonexistent", "data") + DeferCleanup(conf.SetRuntimeInfoForTest("linux", 0)) + viper.Set("enforcenonrootuser", true) + viper.Set("datafolder", nonExistentDataFolder) + + // Attempt to load config as root user - should fail before creating directories + Expect(func() { + conf.Load(true) + }).To(PanicWith(ContainSubstring("EnforceNonRootUser is enabled but Navidrome is running as root"))) + + // Verify that the data folder was NOT created + Expect(nonExistentDataFolder).ToNot(BeAnExistingFile()) + }) + + It("is a no-op on non-unix platforms", func() { + DeferCleanup(conf.SetRuntimeInfoForTest("windows", 0)) + viper.Set("enforcenonrootuser", true) + + conf.Load(true) + + Expect(conf.Server.EnforceNonRootUser).To(BeTrue()) + }) + }) + DescribeTable("should load configuration from", func(format string) { filename := filepath.Join("testdata", "cfg."+format) diff --git a/conf/export_test.go b/conf/export_test.go index 85755aa12..acebca551 100644 --- a/conf/export_test.go +++ b/conf/export_test.go @@ -16,6 +16,17 @@ var ToPascalCase = toPascalCase var ValidateMaxImageUploadSize = validateMaxImageUploadSize +func SetRuntimeInfoForTest(goos string, euid int) func() { + oldGOOS := currentGOOS + oldEUID := getEUID + currentGOOS = func() string { return goos } + getEUID = func() int { return euid } + return func() { + currentGOOS = oldGOOS + getEUID = oldEUID + } +} + func SetLogFatal(f func(...any)) func() { old := logFatal logFatal = f