navidrome/server/server.go
Deluan 055fbde3cf 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.
2026-09-10 08:59:01 -04:00

293 lines
8.8 KiB
Go

package server
import (
"bytes"
"context"
"crypto/tls"
"encoding/pem"
"errors"
"fmt"
"net"
"net/http"
"os"
"path"
"strconv"
"strings"
"time"
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/consts"
"github.com/navidrome/navidrome/core/auth"
"github.com/navidrome/navidrome/core/metrics"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/server/events"
"github.com/navidrome/navidrome/ui"
)
type Server struct {
router chi.Router
ds model.DataStore
appRoot string
broker events.Broker
insights metrics.Insights
}
func New(ds model.DataStore, broker events.Broker, insights metrics.Insights) *Server {
s := &Server{ds: ds, broker: broker, insights: insights}
initialSetup(ds)
auth.Init(s.ds)
s.initRoutes()
s.mountAuthenticationRoutes()
s.mountRootRedirector()
checkFFmpegInstallation()
checkExternalCredentials()
return s
}
func (s *Server) MountRouter(description, urlPath string, subRouter http.Handler) {
urlPath = path.Join(conf.Server.BasePath, urlPath)
log.Info(fmt.Sprintf("Mounting %s routes", description), "path", urlPath)
s.router.Group(func(r chi.Router) {
r.Mount(urlPath, subRouter)
})
}
// Run starts the server with the given address, and if specified, with TLS enabled.
func (s *Server) Run(ctx context.Context, addr string, port int, tlsCert string, tlsKey string) error {
// Mount the router for the frontend assets
s.MountRouter("WebUI", consts.URLPathUI, s.frontendAssetsHandler())
// Create a new http.Server with the specified read header timeout and handler
server := &http.Server{
ReadHeaderTimeout: consts.ServerReadHeaderTimeout,
Handler: s.router,
}
// Determine if TLS is enabled
tlsEnabled := tlsCert != "" && tlsKey != ""
// Validate TLS certificates before starting the server
if tlsEnabled {
if err := validateTLSCertificates(tlsCert, tlsKey); err != nil {
return err
}
}
// Create a listener based on the address type (either Unix socket or TCP)
var listener net.Listener
var err error
if after, ok := strings.CutPrefix(addr, "unix:"); ok {
socketPath := after
listener, err = createUnixSocketFile(socketPath, conf.Server.UnixSocketPerm)
if err != nil {
return err
}
} else {
addr = fmt.Sprintf("%s:%d", addr, port)
listener, err = net.Listen("tcp", addr)
if err != nil {
return fmt.Errorf("creating tcp listener: %w", err)
}
}
// Start the server in a new goroutine and send an error signal to errC if there's an error
errC := make(chan error)
go func() {
var err error
if tlsEnabled {
// Start the HTTPS server
log.Info("Starting server with TLS (HTTPS) enabled", "tlsCert", tlsCert, "tlsKey", tlsKey)
err = server.ServeTLS(listener, tlsCert, tlsKey)
} else {
// Start the HTTP server
err = server.Serve(listener)
}
if !errors.Is(err, http.ErrServerClosed) {
errC <- err
}
}()
// Measure server startup time
startupTime := time.Since(consts.ServerStart)
// Wait a short time to make sure the server has started successfully
select {
case err := <-errC:
log.Error(ctx, "Could not start server. Aborting", err)
return fmt.Errorf("starting server: %w", err)
case <-time.After(50 * time.Millisecond):
log.Info(ctx, "----> Navidrome server is ready!", "address", addr, "startupTime", startupTime, "tlsEnabled", tlsEnabled)
}
// Wait for a signal to terminate
select {
case err := <-errC:
return fmt.Errorf("running server: %w", err)
case <-ctx.Done():
// If the context is done (i.e. the server should stop), proceed to shutting down the server
}
// Try to stop the HTTP server gracefully
log.Info(ctx, "Stopping HTTP server")
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
server.SetKeepAlivesEnabled(false)
if err := server.Shutdown(ctx); err != nil && !errors.Is(err, context.DeadlineExceeded) {
log.Error(ctx, "Unexpected error in http.Shutdown()", err)
}
return nil
}
func createUnixSocketFile(socketPath string, socketPerm string) (net.Listener, error) {
// Remove the socket file if it already exists
if err := os.Remove(socketPath); err != nil && !os.IsNotExist(err) {
return nil, fmt.Errorf("removing previous unix socket file: %w", err)
}
// Create listener
listener, err := net.Listen("unix", socketPath)
if err != nil {
return nil, fmt.Errorf("creating unix socket listener: %w", err)
}
// Converts the socketPerm to uint and updates the permission of the unix socket file
perm, err := strconv.ParseUint(socketPerm, 8, 32)
if err != nil {
return nil, fmt.Errorf("parsing unix socket file permissions: %w", err)
}
err = os.Chmod(socketPath, os.FileMode(perm))
if err != nil {
return nil, fmt.Errorf("updating permission of unix socket file: %w", err)
}
return listener, nil
}
func (s *Server) initRoutes() {
s.appRoot = path.Join(conf.Server.BasePath, consts.URLPathUI)
r := chi.NewRouter()
defaultMiddlewares := chi.Middlewares{
secureMiddleware(),
corsHandler(),
middleware.RequestID,
realIPMiddleware,
middleware.Recoverer,
middleware.Heartbeat("/ping"),
robotsTXT(ui.BuildAssets()),
serverAddressMiddleware,
clientUniqueIDMiddleware,
compressMiddleware(),
loggerInjector,
JWTVerifier,
}
// Mount the Native API /events endpoint with all default middlewares, adding the authentication middlewares
if conf.Server.DevActivityPanel {
r.Group(func(r chi.Router) {
r.Use(defaultMiddlewares...)
r.Use(Authenticator(s.ds))
r.Use(JWTRefresher)
r.Handle(path.Join(conf.Server.BasePath, consts.URLPathNativeAPI, "events"), s.broker)
})
}
// Configure the router with the default middlewares and requestLogger
r.Group(func(r chi.Router) {
r.Use(defaultMiddlewares...)
r.Use(requestLogger)
s.router = r
})
}
func (s *Server) mountAuthenticationRoutes() chi.Router {
r := s.router
return r.Route(path.Join(conf.Server.BasePath, "/auth"), func(r chi.Router) {
if conf.Server.AuthRequestLimit > 0 {
log.Info("Login rate limit set", "requestLimit", conf.Server.AuthRequestLimit,
"windowLength", 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")
r.Post("/login", login(s.ds))
}
r.Post("/createAdmin", createAdmin(s.ds))
})
}
// Serve UI app assets
func (s *Server) mountRootRedirector() {
r := s.router
// Redirect root to UI URL
r.Get("/*", func(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, s.appRoot+"/", http.StatusFound)
})
r.Get(s.appRoot, func(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, s.appRoot+"/", http.StatusFound)
})
}
func (s *Server) frontendAssetsHandler() http.Handler {
r := chi.NewRouter()
r.Handle("/", Index(s.ds, ui.BuildAssets()))
r.Handle("/*", http.StripPrefix(s.appRoot, http.FileServer(http.FS(ui.BuildAssets()))))
return r
}
// validateTLSCertificates validates the TLS certificate and key files before starting the server.
// It provides detailed error messages for common issues like encrypted private keys.
func validateTLSCertificates(certFile, keyFile string) error {
// Read the key file to check for encryption
keyData, err := os.ReadFile(keyFile) //nolint:gosec
if err != nil {
return fmt.Errorf("reading TLS key file: %w", err)
}
// Parse PEM blocks and check for encryption
block, _ := pem.Decode(keyData)
if block == nil {
return errors.New("TLS key file does not contain a valid PEM block")
}
// Check for encrypted private key indicators
if isEncryptedPEM(block, keyData) {
return errors.New("TLS private key is encrypted (password-protected). " +
"Navidrome does not support encrypted private keys. " +
"Please decrypt your key using: openssl pkey -in <encrypted-key> -out <decrypted-key>")
}
// Try to load the certificate pair to validate it
_, err = tls.LoadX509KeyPair(certFile, keyFile)
if err != nil {
return fmt.Errorf("loading TLS certificate/key pair: %w", err)
}
return nil
}
// isEncryptedPEM checks if a PEM block represents an encrypted private key.
func isEncryptedPEM(block *pem.Block, rawData []byte) bool {
// Check for PKCS#8 encrypted format (BEGIN ENCRYPTED PRIVATE KEY)
if block.Type == "ENCRYPTED PRIVATE KEY" {
return true
}
// Check for legacy encrypted format with Proc-Type header
if block.Headers != nil {
if procType, ok := block.Headers["Proc-Type"]; ok && strings.Contains(procType, "ENCRYPTED") {
return true
}
}
// Also check raw data for DEK-Info header (in case pem.Decode doesn't parse headers correctly)
if bytes.Contains(rawData, []byte("DEK-Info:")) || bytes.Contains(rawData, []byte("Proc-Type: 4,ENCRYPTED")) {
return true
}
return false
}