feat(server): add EnforceNonRootUser config option to exit early if started as root (#5373)
Some checks are pending
Pipeline: Test, Lint, Build / Get version info (push) Waiting to run
Pipeline: Test, Lint, Build / Lint Go code (push) Waiting to run
Pipeline: Test, Lint, Build / Test Go code (push) Waiting to run
Pipeline: Test, Lint, Build / Test Go code (Windows) (push) Waiting to run
Pipeline: Test, Lint, Build / Test JS code (push) Waiting to run
Pipeline: Test, Lint, Build / Lint i18n files (push) Waiting to run
Pipeline: Test, Lint, Build / Check Docker configuration (push) Waiting to run
Pipeline: Test, Lint, Build / Build (push) Blocked by required conditions
Pipeline: Test, Lint, Build / Build-1 (push) Blocked by required conditions
Pipeline: Test, Lint, Build / Build-2 (push) Blocked by required conditions
Pipeline: Test, Lint, Build / Build-3 (push) Blocked by required conditions
Pipeline: Test, Lint, Build / Build-4 (push) Blocked by required conditions
Pipeline: Test, Lint, Build / Build-5 (push) Blocked by required conditions
Pipeline: Test, Lint, Build / Build-6 (push) Blocked by required conditions
Pipeline: Test, Lint, Build / Build-7 (push) Blocked by required conditions
Pipeline: Test, Lint, Build / Build-8 (push) Blocked by required conditions
Pipeline: Test, Lint, Build / Build-9 (push) Blocked by required conditions
Pipeline: Test, Lint, Build / Build-10 (push) Blocked by required conditions
Pipeline: Test, Lint, Build / Push to GHCR (push) Blocked by required conditions
Pipeline: Test, Lint, Build / Push to Docker Hub (push) Blocked by required conditions
Pipeline: Test, Lint, Build / Cleanup digest artifacts (push) Blocked by required conditions
Pipeline: Test, Lint, Build / Build Windows installers (push) Blocked by required conditions
Pipeline: Test, Lint, Build / Package/Release (push) Blocked by required conditions
Pipeline: Test, Lint, Build / Upload Linux PKG (push) Blocked by required conditions

* feat(config): Add EnforceNonRootUser config option to exit early if started as root

Signed-off-by: Aengus Walton <ventolin@gmail.com>

* Move validateEnforceNonRootUser check to directly after parsing the config

* Ensure the data directory hasn't been created in test

---------

Signed-off-by: Aengus Walton <ventolin@gmail.com>
Co-authored-by: Deluan Quintão <deluan@navidrome.org>
This commit is contained in:
Aengus Walton 2026-04-22 03:27:54 +02:00 committed by GitHub
parent 2954c052f5
commit 44e63596a0
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 79 additions and 0 deletions

View file

@ -27,6 +27,7 @@ type configOptions struct {
Address string Address string
Port int Port int
UnixSocketPerm string UnixSocketPerm string
EnforceNonRootUser bool
MusicFolder string MusicFolder string
DataFolder string DataFolder string
CacheFolder string CacheFolder string
@ -273,6 +274,12 @@ var logFatal = func(args ...any) {
os.Exit(1) os.Exit(1)
} }
var getEUID = os.Geteuid
var currentGOOS = func() string {
return runtime.GOOS
}
var ( var (
Server = &configOptions{} Server = &configOptions{}
hooks []func() hooks []func()
@ -303,6 +310,11 @@ func Load(noConfigDump bool) {
logFatal("Error parsing config:", err) 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) err = os.MkdirAll(Server.DataFolder, os.ModePerm)
if err != nil { if err != nil {
logFatal("Error creating data path:", err) logFatal("Error creating data path:", err)
@ -599,6 +611,18 @@ func validateMaxImageUploadSize() error {
return nil 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 { func validateScanSchedule() error {
if Server.Scanner.Schedule == "0" || Server.Scanner.Schedule == "" { if Server.Scanner.Schedule == "0" || Server.Scanner.Schedule == "" {
Server.Scanner.Schedule = "" Server.Scanner.Schedule = ""
@ -698,6 +722,7 @@ func setViperDefaults() {
viper.SetDefault("address", "0.0.0.0") viper.SetDefault("address", "0.0.0.0")
viper.SetDefault("port", 4533) viper.SetDefault("port", 4533)
viper.SetDefault("unixsocketperm", "0660") viper.SetDefault("unixsocketperm", "0660")
viper.SetDefault("enforcenonrootuser", false)
viper.SetDefault("sessiontimeout", consts.DefaultSessionTimeout) viper.SetDefault("sessiontimeout", consts.DefaultSessionTimeout)
viper.SetDefault("baseurl", "") viper.SetDefault("baseurl", "")
viper.SetDefault("tlscert", "") viper.SetDefault("tlscert", "")

View file

@ -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", DescribeTable("should load configuration from",
func(format string) { func(format string) {
filename := filepath.Join("testdata", "cfg."+format) filename := filepath.Join("testdata", "cfg."+format)

View file

@ -16,6 +16,17 @@ var ToPascalCase = toPascalCase
var ValidateMaxImageUploadSize = validateMaxImageUploadSize 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() { func SetLogFatal(f func(...any)) func() {
old := logFatal old := logFatal
logFatal = f logFatal = f