Merge 07b0584a664a55e6a49f95ef028094c681b301b9 into 28d5299ffc02498a63a8d1618a0d376631ef1f9b

This commit is contained in:
crazygolem 2025-11-15 15:25:54 +01:00 committed by GitHub
commit 291d3bfdd9
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 44 additions and 27 deletions

View File

@ -86,8 +86,7 @@ type configOptions struct {
AuthRequestLimit int AuthRequestLimit int
AuthWindowLength time.Duration AuthWindowLength time.Duration
PasswordEncryptionKey string PasswordEncryptionKey string
ReverseProxyUserHeader string ExtAuth extAuthOptions
ReverseProxyWhitelist string
Plugins pluginsOptions Plugins pluginsOptions
PluginConfig map[string]map[string]string PluginConfig map[string]map[string]string
HTTPSecurityHeaders secureOptions `json:",omitzero"` HTTPSecurityHeaders secureOptions `json:",omitzero"`
@ -227,6 +226,11 @@ type pluginsOptions struct {
CacheSize string CacheSize string
} }
type extAuthOptions struct {
TrustedSources string
UserHeader string
}
var ( var (
Server = &configOptions{} Server = &configOptions{}
hooks []func() hooks []func()
@ -244,6 +248,7 @@ func LoadFromFile(confFile string) {
func Load(noConfigDump bool) { func Load(noConfigDump bool) {
parseIniFileConfiguration() parseIniFileConfiguration()
mapDeprecatedOptions()
err := viper.Unmarshal(&Server) err := viper.Unmarshal(&Server)
if err != nil { if err != nil {
@ -348,6 +353,7 @@ func Load(noConfigDump bool) {
logDeprecatedOptions("Scanner.GenreSeparators") logDeprecatedOptions("Scanner.GenreSeparators")
logDeprecatedOptions("Scanner.GroupAlbumReleases") logDeprecatedOptions("Scanner.GroupAlbumReleases")
logDeprecatedOptions("DevEnableBufferedScrobble") // Deprecated: Buffered scrobbling is now always enabled and this option is ignored logDeprecatedOptions("DevEnableBufferedScrobble") // Deprecated: Buffered scrobbling is now always enabled and this option is ignored
logDeprecatedOptions("ReverseProxyWhitelist", "ReverseProxyUserHeader")
// Call init hooks // Call init hooks
for _, hook := range hooks { for _, hook := range hooks {
@ -367,6 +373,17 @@ func logDeprecatedOptions(options ...string) {
} }
} }
// mapDeprecatedOptions is used to provide backwards compatibility for deprecated options. It should be called after
// the config has been read by viper, but before unmarshalling it into the Config struct.
func mapDeprecatedOptions() {
if viper.IsSet("ReverseProxyWhitelist") {
viper.Set("ExtAuth.TrustedSources", viper.Get("ReverseProxyWhitelist"))
}
if viper.IsSet("ReverseProxyUserHeader") {
viper.Set("ExtAuth.UserHeader", viper.Get("ReverseProxyUserHeader"))
}
}
// parseIniFileConfiguration is used to parse the config file when it is in INI format. For INI files, it // parseIniFileConfiguration is used to parse the config file when it is in INI format. For INI files, it
// would require a nested structure, so instead we unmarshal it to a map and then merge the nested [default] // would require a nested structure, so instead we unmarshal it to a map and then merge the nested [default]
// section into the root level. // section into the root level.
@ -534,8 +551,8 @@ func setViperDefaults() {
viper.SetDefault("authrequestlimit", 5) viper.SetDefault("authrequestlimit", 5)
viper.SetDefault("authwindowlength", 20*time.Second) viper.SetDefault("authwindowlength", 20*time.Second)
viper.SetDefault("passwordencryptionkey", "") viper.SetDefault("passwordencryptionkey", "")
viper.SetDefault("reverseproxyuserheader", "Remote-User") viper.SetDefault("extauth.userheader", "Remote-User")
viper.SetDefault("reverseproxywhitelist", "") viper.SetDefault("extauth.trustedsources", "")
viper.SetDefault("prometheus.enabled", false) viper.SetDefault("prometheus.enabled", false)
viper.SetDefault("prometheus.metricspath", consts.PrometheusDefaultPath) viper.SetDefault("prometheus.metricspath", consts.PrometheusDefaultPath)
viper.SetDefault("prometheus.password", "") viper.SetDefault("prometheus.password", "")

View File

@ -215,7 +215,7 @@ var staticData = sync.OnceValue(func() insights.Data {
data.Config.ScanSchedule = conf.Server.Scanner.Schedule data.Config.ScanSchedule = conf.Server.Scanner.Schedule
data.Config.ScanWatcherWait = uint64(math.Trunc(conf.Server.Scanner.WatcherWait.Seconds())) data.Config.ScanWatcherWait = uint64(math.Trunc(conf.Server.Scanner.WatcherWait.Seconds()))
data.Config.ScanOnStartup = conf.Server.Scanner.ScanOnStartup data.Config.ScanOnStartup = conf.Server.Scanner.ScanOnStartup
data.Config.ReverseProxyConfigured = conf.Server.ReverseProxyWhitelist != "" data.Config.ReverseProxyConfigured = conf.Server.ExtAuth.TrustedSources != ""
data.Config.HasCustomPID = conf.Server.PID.Track != "" || conf.Server.PID.Album != "" data.Config.HasCustomPID = conf.Server.PID.Track != "" || conf.Server.PID.Album != ""
data.Config.HasCustomTags = len(conf.Server.Tags) > 0 data.Config.HasCustomTags = len(conf.Server.Tags) > 0

View File

@ -29,8 +29,8 @@ var redacted = &Hook{
"(Secret:\")[\\w]*", "(Secret:\")[\\w]*",
"(Spotify.*ID:\")[\\w]*", "(Spotify.*ID:\")[\\w]*",
"(PasswordEncryptionKey:[\\s]*\")[^\"]*", "(PasswordEncryptionKey:[\\s]*\")[^\"]*",
"(ReverseProxyUserHeader:[\\s]*\")[^\"]*", "(UserHeader:[\\s]*\")[^\"]*",
"(ReverseProxyWhitelist:[\\s]*\")[^\"]*", "(TrustedSources:[\\s]*\")[^\"]*",
"(MetricsPath:[\\s]*\")[^\"]*", "(MetricsPath:[\\s]*\")[^\"]*",
"(DevAutoCreateAdminPassword:[\\s]*\")[^\"]*", "(DevAutoCreateAdminPassword:[\\s]*\")[^\"]*",
"(DevAutoLoginUsername:[\\s]*\")[^\"]*", "(DevAutoLoginUsername:[\\s]*\")[^\"]*",

View File

@ -193,24 +193,24 @@ func UsernameFromToken(r *http.Request) string {
return token.Subject() return token.Subject()
} }
func UsernameFromReverseProxyHeader(r *http.Request) string { func UsernameFromExtAuthHeader(r *http.Request) string {
if conf.Server.ReverseProxyWhitelist == "" { if conf.Server.ExtAuth.TrustedSources == "" {
return "" return ""
} }
reverseProxyIp, ok := request.ReverseProxyIpFrom(r.Context()) reverseProxyIp, ok := request.ReverseProxyIpFrom(r.Context())
if !ok { if !ok {
log.Error("ReverseProxyWhitelist enabled but no proxy IP found in request context. Please report this error.") log.Error("ExtAuth enabled but no proxy IP found in request context. Please report this error.")
return "" return ""
} }
if !validateIPAgainstList(reverseProxyIp, conf.Server.ReverseProxyWhitelist) { if !validateIPAgainstList(reverseProxyIp, conf.Server.ExtAuth.TrustedSources) {
log.Warn(r.Context(), "IP is not whitelisted for reverse proxy login", "proxy-ip", reverseProxyIp, "client-ip", r.RemoteAddr) log.Warn(r.Context(), "IP is not whitelisted for external authentication", "proxy-ip", reverseProxyIp, "client-ip", r.RemoteAddr)
return "" return ""
} }
username := r.Header.Get(conf.Server.ReverseProxyUserHeader) username := r.Header.Get(conf.Server.ExtAuth.UserHeader)
if username == "" { if username == "" {
return "" return ""
} }
log.Trace(r, "Found username in ReverseProxyUserHeader", "username", username) log.Trace(r, "Found username in ExtAuth.UserHeader", "username", username)
return username return username
} }
@ -256,7 +256,7 @@ func authenticateRequest(ds model.DataStore, r *http.Request, findUsernameFns ..
func Authenticator(ds model.DataStore) func(next http.Handler) http.Handler { func Authenticator(ds model.DataStore) func(next http.Handler) http.Handler {
return func(next http.Handler) http.Handler { return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ctx, err := authenticateRequest(ds, r, UsernameFromConfig, UsernameFromToken, UsernameFromReverseProxyHeader) ctx, err := authenticateRequest(ds, r, UsernameFromConfig, UsernameFromToken, UsernameFromExtAuthHeader)
if err != nil { if err != nil {
_ = rest.RespondWithError(w, http.StatusUnauthorized, "Not authenticated") _ = rest.RespondWithError(w, http.StatusUnauthorized, "Not authenticated")
return return
@ -291,7 +291,7 @@ func JWTRefresher(next http.Handler) http.Handler {
func handleLoginFromHeaders(ds model.DataStore, r *http.Request) map[string]interface{} { func handleLoginFromHeaders(ds model.DataStore, r *http.Request) map[string]interface{} {
username := UsernameFromConfig(r) username := UsernameFromConfig(r)
if username == "" { if username == "" {
username = UsernameFromReverseProxyHeader(r) username = UsernameFromExtAuthHeader(r)
if username == "" { if username == "" {
return nil return nil
} }

View File

@ -80,7 +80,7 @@ var _ = Describe("Auth", func() {
req.Header.Add("Remote-User", "janedoe") req.Header.Add("Remote-User", "janedoe")
resp = httptest.NewRecorder() resp = httptest.NewRecorder()
conf.Server.UILoginBackgroundURL = "" conf.Server.UILoginBackgroundURL = ""
conf.Server.ReverseProxyWhitelist = "192.168.0.0/16,2001:4860:4860::/48" conf.Server.ExtAuth.TrustedSources = "192.168.0.0/16,2001:4860:4860::/48"
}) })
It("sets auth data if IPv4 matches whitelist", func() { It("sets auth data if IPv4 matches whitelist", func() {
@ -155,7 +155,7 @@ var _ = Describe("Auth", func() {
It("does not set auth data when listening on unix socket without whitelist", func() { It("does not set auth data when listening on unix socket without whitelist", func() {
conf.Server.Address = "unix:/tmp/navidrome-test" conf.Server.Address = "unix:/tmp/navidrome-test"
conf.Server.ReverseProxyWhitelist = "" conf.Server.ExtAuth.TrustedSources = ""
// No ReverseProxyIp in request context // No ReverseProxyIp in request context
serveIndex(ds, fs, nil)(resp, req) serveIndex(ds, fs, nil)(resp, req)
@ -176,7 +176,7 @@ var _ = Describe("Auth", func() {
It("sets auth data when listening on unix socket with correct whitelist", func() { It("sets auth data when listening on unix socket with correct whitelist", func() {
conf.Server.Address = "unix:/tmp/navidrome-test" conf.Server.Address = "unix:/tmp/navidrome-test"
conf.Server.ReverseProxyWhitelist = conf.Server.ReverseProxyWhitelist + ",@" conf.Server.ExtAuth.TrustedSources = conf.Server.ExtAuth.TrustedSources + ",@"
req = req.WithContext(request.WithReverseProxyIp(req.Context(), "@")) req = req.WithContext(request.WithReverseProxyIp(req.Context(), "@"))
serveIndex(ds, fs, nil)(resp, req) serveIndex(ds, fs, nil)(resp, req)
@ -302,8 +302,8 @@ var _ = Describe("Auth", func() {
ds = &tests.MockDataStore{} ds = &tests.MockDataStore{}
req = httptest.NewRequest("GET", "/", nil) req = httptest.NewRequest("GET", "/", nil)
req = req.WithContext(request.WithReverseProxyIp(req.Context(), trustedIP)) req = req.WithContext(request.WithReverseProxyIp(req.Context(), trustedIP))
conf.Server.ReverseProxyWhitelist = "192.168.0.0/16" conf.Server.ExtAuth.TrustedSources = "192.168.0.0/16"
conf.Server.ReverseProxyUserHeader = "Remote-User" conf.Server.ExtAuth.UserHeader = "Remote-User"
}) })
It("makes the first user an admin", func() { It("makes the first user an admin", func() {

View File

@ -168,7 +168,7 @@ func clientUniqueIDMiddleware(next http.Handler) http.Handler {
// realIPMiddleware applies middleware.RealIP, and additionally saves the request's original RemoteAddr to the request's // realIPMiddleware applies middleware.RealIP, and additionally saves the request's original RemoteAddr to the request's
// context if navidrome is behind a trusted reverse proxy. // context if navidrome is behind a trusted reverse proxy.
func realIPMiddleware(next http.Handler) http.Handler { func realIPMiddleware(next http.Handler) http.Handler {
if conf.Server.ReverseProxyWhitelist != "" { if conf.Server.ExtAuth.TrustedSources != "" {
return chi.Chain( return chi.Chain(
reqToCtx(request.ReverseProxyIp, func(r *http.Request) any { return r.RemoteAddr }), reqToCtx(request.ReverseProxyIp, func(r *http.Request) any { return r.RemoteAddr }),
middleware.RealIP, middleware.RealIP,

View File

@ -56,7 +56,7 @@ func fromInternalOrProxyAuth(r *http.Request) (string, bool) {
return username, true return username, true
} }
return server.UsernameFromReverseProxyHeader(r), false return server.UsernameFromExtAuthHeader(r), false
} }
func checkRequiredParameters(next http.Handler) http.Handler { func checkRequiredParameters(next http.Handler) http.Handler {

View File

@ -95,8 +95,8 @@ var _ = Describe("Middlewares", func() {
}) })
It("passes when all required params are available (reverse-proxy case)", func() { It("passes when all required params are available (reverse-proxy case)", func() {
conf.Server.ReverseProxyWhitelist = "127.0.0.234/32" conf.Server.ExtAuth.TrustedSources = "127.0.0.234/32"
conf.Server.ReverseProxyUserHeader = "Remote-User" conf.Server.ExtAuth.UserHeader = "Remote-User"
r := newGetRequest("v=1.15", "c=test") r := newGetRequest("v=1.15", "c=test")
r.Header.Add("Remote-User", "user") r.Header.Add("Remote-User", "user")
@ -254,8 +254,8 @@ var _ = Describe("Middlewares", func() {
When("using reverse proxy authentication", func() { When("using reverse proxy authentication", func() {
BeforeEach(func() { BeforeEach(func() {
DeferCleanup(configtest.SetupConfig()) DeferCleanup(configtest.SetupConfig())
conf.Server.ReverseProxyWhitelist = "192.168.1.1/24" conf.Server.ExtAuth.TrustedSources = "192.168.1.1/24"
conf.Server.ReverseProxyUserHeader = "Remote-User" conf.Server.ExtAuth.UserHeader = "Remote-User"
}) })
It("passes authentication with correct IP and header", func() { It("passes authentication with correct IP and header", func() {