mirror of
https://github.com/navidrome/navidrome.git
synced 2026-10-08 10:27:08 +02:00
fix(server): key the login rate limit on a trust-aware client IP
The RealIP middleware rewrote RemoteAddr from the True-Client-IP, X-Real-IP and X-Forwarded-For headers on every request, including when no trusted reverse proxy was configured. The login rate limiters on /auth/login and the Jellyfin /Users/AuthenticateByName derived their bucket from that value, so an unauthenticated client could rotate a forwarding header and get a fresh bucket for every password attempt, defeating the brute-force protection. Resolve the client IP with chi's ClientIPFrom* middlewares instead. The forwarding headers are only honoured when ExtAuth.TrustedSources is set and the connecting peer is in that list, reusing the trust check that external authentication already applies; otherwise the peer address is used. The X-Forwarded-For chain is now walked against the trusted CIDRs rather than taking its leftmost entry, so a spoofed value prepended by the client is skipped. Both limiters now key on the resolved address. The resolved address is still mirrored into RemoteAddr, so request logging, player registration and the Jellyfin local-network check keep reporting the client rather than the proxy. Reported by gehan-psbc.
This commit is contained in:
parent
72975a95fb
commit
055fbde3cf
6 changed files with 195 additions and 17 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue