diff --git a/server/jellyfin/api.go b/server/jellyfin/api.go index 6484a3bb4..b0a5350b3 100644 --- a/server/jellyfin/api.go +++ b/server/jellyfin/api.go @@ -7,7 +7,6 @@ import ( "time" "github.com/go-chi/chi/v5" - "github.com/go-chi/httprate" "golang.org/x/sync/singleflight" "github.com/navidrome/navidrome/conf" @@ -78,9 +77,9 @@ func (api *Router) routes() http.Handler { inner.Post("/system/ping", api.ping) inner.Get("/quickconnect/enabled", api.quickConnectEnabled) // Rate-limit the password login, mirroring the native /auth/login: it's an unauthenticated - // brute-force surface, so it must share the same per-IP throttle when one is configured. + // brute-force surface, so it must share the same per-client throttle when one is configured. if conf.Server.AuthRequestLimit > 0 { - limiter := httprate.LimitByIP(conf.Server.AuthRequestLimit, conf.Server.AuthWindowLength) + limiter := server.ClientIPRateLimiter(conf.Server.AuthRequestLimit, conf.Server.AuthWindowLength) inner.With(limiter).Post("/users/authenticatebyname", api.authenticateByName) } else { inner.Post("/users/authenticatebyname", api.authenticateByName) diff --git a/server/jellyfin/api_test.go b/server/jellyfin/api_test.go index fe69c8321..605303793 100644 --- a/server/jellyfin/api_test.go +++ b/server/jellyfin/api_test.go @@ -6,6 +6,7 @@ import ( "strings" "time" + "github.com/go-chi/chi/v5/middleware" "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/conf/configtest" "github.com/navidrome/navidrome/core/auth" @@ -84,4 +85,26 @@ var _ = Describe("Router", func() { Expect(login()).To(Equal(http.StatusUnauthorized)) Expect(login()).To(Equal(http.StatusTooManyRequests)) }) + + It("rate-limits AuthenticateByName by resolved client IP, not by the proxy connection", func() { + DeferCleanup(configtest.SetupConfig()) + conf.Server.AuthRequestLimit = 1 + conf.Server.AuthWindowLength = time.Minute + api := New(&tests.MockDataStore{}, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil) + // Every request arrives on the same proxy connection, so only the resolved client IP + // can separate the buckets. + handler := middleware.ClientIPFromHeader("X-Real-IP")(api) + + login := func(clientIP string) int { + w := httptest.NewRecorder() + r := httptest.NewRequest("POST", "/Users/AuthenticateByName", strings.NewReader(`{"Username":"x","Pw":"y"}`)) + r.RemoteAddr = "10.0.0.1:1234" + r.Header.Set("X-Real-IP", clientIP) + handler.ServeHTTP(w, r) + return w.Code + } + Expect(login("203.0.113.1")).To(Equal(http.StatusUnauthorized)) + Expect(login("203.0.113.1")).To(Equal(http.StatusTooManyRequests)) + Expect(login("203.0.113.2")).To(Equal(http.StatusUnauthorized)) + }) }) diff --git a/server/jellyfin/system.go b/server/jellyfin/system.go index 8b847987f..ccc1277df 100644 --- a/server/jellyfin/system.go +++ b/server/jellyfin/system.go @@ -136,7 +136,7 @@ func isSameMachine(r *http.Request, remote netip.Addr) bool { return parseIP(local.String()) == remote } -// remoteIP parses RemoteAddr, which the RealIP middleware may have rewritten to a bare IP. +// remoteIP parses RemoteAddr, which realIPMiddleware may have rewritten to a bare client IP. func remoteIP(r *http.Request) netip.Addr { return parseIP(r.RemoteAddr) } diff --git a/server/middlewares.go b/server/middlewares.go index b710b4068..c54e947f3 100644 --- a/server/middlewares.go +++ b/server/middlewares.go @@ -7,7 +7,9 @@ import ( "errors" "fmt" "io/fs" + "net" "net/http" + "net/netip" "net/url" "strings" "time" @@ -15,6 +17,7 @@ import ( "github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5/middleware" "github.com/go-chi/cors" + "github.com/go-chi/httprate" "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/consts" "github.com/navidrome/navidrome/log" @@ -165,20 +168,77 @@ func clientUniqueIDMiddleware(next http.Handler) http.Handler { }) } -// 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. +// realIPMiddleware resolves the request's client IP into the context, where it can be read with +// middleware.GetClientIP, and mirrors it into RemoteAddr for logging and player registration. +// Forwarding headers are only honoured when the peer is listed in ExtAuth.TrustedSources, so that +// a client cannot pick its own identity and evade controls keyed on it. The peer address is kept +// in the context as request.ReverseProxyIp. func realIPMiddleware(next http.Handler) http.Handler { - if conf.Server.ExtAuth.TrustedSources != "" { - return chi.Chain( - reqToCtx(request.ReverseProxyIp, func(r *http.Request) any { return r.RemoteAddr }), - middleware.RealIP, - ).Handler(next) + trusted := conf.Server.ExtAuth.TrustedSources + fromPeer := middleware.ClientIPFromRemoteAddr(next) + if trusted == "" { + return fromPeer } - // The middleware is applied without a trusted reverse proxy to support other use-cases such as multiple clients - // behind a caching proxy. In this case, navidrome only uses the request's RemoteAddr for logging, so the security - // impact of reading the headers from untrusted sources is limited. - return middleware.RealIP(next) + // Last match wins, so this order reproduces RealIP's precedence: True-Client-IP, X-Real-IP, + // X-Forwarded-For, peer. Only X-Forwarded-For is checked against the trusted list. + fromProxy := chi.Chain( + middleware.ClientIPFromRemoteAddr, + middleware.ClientIPFromXFF(trustedProxyPrefixes(trusted)...), + middleware.ClientIPFromHeader("X-Real-IP"), + middleware.ClientIPFromHeader("True-Client-IP"), + ).Handler(mirrorClientIP(next)) + + dispatch := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if validateIPAgainstList(r.RemoteAddr, trusted) { + fromProxy.ServeHTTP(w, r) + return + } + log.Trace(r.Context(), "Ignoring forwarding headers from untrusted peer", "peer", r.RemoteAddr) + fromPeer.ServeHTTP(w, r) + }) + return reqToCtx(request.ReverseProxyIp, func(r *http.Request) any { return r.RemoteAddr })(dispatch) +} + +// mirrorClientIP copies the resolved client IP into RemoteAddr when it differs from the peer. +func mirrorClientIP(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if ip := middleware.GetClientIP(r.Context()); ip != "" && ip != peerHost(r) { + r.RemoteAddr = ip + } + next.ServeHTTP(w, r) + }) +} + +// peerHost returns the host part of RemoteAddr, which may already be a bare IP. +func peerHost(r *http.Request) string { + if host, _, err := net.SplitHostPort(r.RemoteAddr); err == nil { + return host + } + return r.RemoteAddr +} + +// trustedProxyPrefixes returns the CIDR entries of a trusted sources list, skipping non-CIDR +// entries such as the "@" unix socket marker. An empty result makes ClientIPFromXFF trust +// exactly one hop. +func trustedProxyPrefixes(list string) []string { + var prefixes []string + for _, entry := range strings.Split(list, ",") { + entry = strings.TrimSpace(entry) + if _, err := netip.ParsePrefix(entry); err == nil { + prefixes = append(prefixes, entry) + } + } + return prefixes +} + +// ClientIPRateLimiter returns a rate limiter keyed by the client IP resolved by realIPMiddleware, +// so spoofed forwarding headers cannot be rotated for a fresh bucket. It falls back to the peer +// address, so that a missing middleware degrades to per-peer limiting rather than one shared bucket. +func ClientIPRateLimiter(requestLimit int, windowLength time.Duration) func(http.Handler) http.Handler { + return httprate.LimitBy(requestLimit, windowLength, func(r *http.Request) (string, error) { + return httprate.CanonicalizeIP(cmp.Or(middleware.GetClientIP(r.Context()), peerHost(r))), nil + }) } // reqToCtx creates a middleware that updates the request's context with a value computed from the request. A given key diff --git a/server/middlewares_test.go b/server/middlewares_test.go index 4ba9a853b..a9b0bc99e 100644 --- a/server/middlewares_test.go +++ b/server/middlewares_test.go @@ -9,6 +9,7 @@ import ( "time" "github.com/go-chi/chi/v5" + "github.com/go-chi/chi/v5/middleware" "github.com/google/uuid" "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/conf/configtest" @@ -435,4 +436,100 @@ var _ = Describe("middlewares", func() { }) }) }) + Describe("realIPMiddleware", func() { + var resolved, remoteAddr string + var proxyIP any + next := func(w http.ResponseWriter, r *http.Request) { + resolved = middleware.GetClientIP(r.Context()) + remoteAddr = r.RemoteAddr + proxyIP = r.Context().Value(request.ReverseProxyIp) + } + call := func(peer string, headers map[string]string) { + resolved, remoteAddr, proxyIP = "", "", nil + r := httptest.NewRequest("POST", "/auth/login", nil) + r.RemoteAddr = peer + for k, v := range headers { + r.Header.Set(k, v) + } + realIPMiddleware(http.HandlerFunc(next)).ServeHTTP(httptest.NewRecorder(), r) + } + + Context("without a trusted proxy", func() { + It("ignores client-supplied forwarding headers", func() { + call("10.0.0.1:1234", map[string]string{ + "X-Forwarded-For": "203.0.113.5", + "X-Real-IP": "203.0.113.6", + "True-Client-IP": "203.0.113.7", + }) + Expect(resolved).To(Equal("10.0.0.1")) + }) + It("leaves RemoteAddr untouched", func() { + call("10.0.0.1:1234", map[string]string{"X-Forwarded-For": "203.0.113.5"}) + Expect(remoteAddr).To(Equal("10.0.0.1:1234")) + }) + }) + + Context("with a trusted proxy", func() { + BeforeEach(func() { + conf.Server.ExtAuth.TrustedSources = "10.0.0.0/8" + }) + It("uses the forwarded client IP when the peer is a trusted proxy", func() { + call("10.0.0.1:1234", map[string]string{"X-Forwarded-For": "203.0.113.5, 10.0.0.1"}) + Expect(resolved).To(Equal("203.0.113.5")) + Expect(remoteAddr).To(Equal("203.0.113.5")) + }) + It("honours X-Real-IP from a trusted proxy", func() { + call("10.0.0.1:1234", map[string]string{"X-Real-IP": "203.0.113.6"}) + Expect(resolved).To(Equal("203.0.113.6")) + }) + It("ignores forwarding headers when the peer is not a trusted proxy", func() { + call("198.51.100.9:1234", map[string]string{"X-Forwarded-For": "203.0.113.5"}) + Expect(resolved).To(Equal("198.51.100.9")) + Expect(remoteAddr).To(Equal("198.51.100.9:1234")) + }) + It("keeps the peer address in the context for external auth", func() { + call("10.0.0.1:1234", map[string]string{"X-Forwarded-For": "203.0.113.5"}) + Expect(proxyIP).To(Equal("10.0.0.1:1234")) + }) + }) + }) + + Describe("ClientIPRateLimiter", func() { + var handler http.Handler + JustBeforeEach(func() { + handler = realIPMiddleware(ClientIPRateLimiter(2, time.Minute)( + http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) }))) + }) + attempt := func(peer string, header, value string) int { + r := httptest.NewRequest("POST", "/auth/login", nil) + r.RemoteAddr = peer + r.Header.Set(header, value) + w := httptest.NewRecorder() + handler.ServeHTTP(w, r) + return w.Code + } + + DescribeTable("keeps one bucket per peer when the forwarding header is rotated", + func(header string) { + Expect(attempt("198.51.100.9:1", header, "203.0.113.1")).To(Equal(http.StatusOK)) + Expect(attempt("198.51.100.9:2", header, "203.0.113.2")).To(Equal(http.StatusOK)) + Expect(attempt("198.51.100.9:3", header, "203.0.113.3")).To(Equal(http.StatusTooManyRequests)) + }, + Entry("X-Forwarded-For", "X-Forwarded-For"), + Entry("X-Real-IP", "X-Real-IP"), + Entry("True-Client-IP", "True-Client-IP"), + ) + + Context("behind a trusted proxy", func() { + BeforeEach(func() { + conf.Server.ExtAuth.TrustedSources = "10.0.0.0/8" + }) + It("gives each real client its own bucket", func() { + Expect(attempt("10.0.0.1:1", "X-Forwarded-For", "203.0.113.1")).To(Equal(http.StatusOK)) + Expect(attempt("10.0.0.1:2", "X-Forwarded-For", "203.0.113.1")).To(Equal(http.StatusOK)) + Expect(attempt("10.0.0.1:3", "X-Forwarded-For", "203.0.113.1")).To(Equal(http.StatusTooManyRequests)) + Expect(attempt("10.0.0.1:4", "X-Forwarded-For", "203.0.113.2")).To(Equal(http.StatusOK)) + }) + }) + }) }) diff --git a/server/server.go b/server/server.go index b05c20cc5..7e7cfa3ea 100644 --- a/server/server.go +++ b/server/server.go @@ -17,7 +17,6 @@ import ( "github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5/middleware" - "github.com/go-chi/httprate" "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/consts" "github.com/navidrome/navidrome/core/auth" @@ -209,7 +208,7 @@ func (s *Server) mountAuthenticationRoutes() chi.Router { log.Info("Login rate limit set", "requestLimit", conf.Server.AuthRequestLimit, "windowLength", conf.Server.AuthWindowLength) - rateLimiter := httprate.LimitByIP(conf.Server.AuthRequestLimit, conf.Server.AuthWindowLength) + rateLimiter := ClientIPRateLimiter(conf.Server.AuthRequestLimit, conf.Server.AuthWindowLength) r.With(rateLimiter).Post("/login", login(s.ds)) } else { log.Warn("Login rate limit is disabled! Consider enabling it to be protected against brute-force attacks")