navidrome/server/jellyfin/system.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

157 lines
5.4 KiB
Go

package jellyfin
import (
"context"
"encoding/hex"
"errors"
"fmt"
"net"
"net/http"
"net/netip"
"path"
"strings"
"github.com/google/uuid"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/consts"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/server"
"github.com/navidrome/navidrome/server/jellyfin/dto"
)
// jellyfinVersion is the Jellyfin API version advertised in the handshake. Clients feature-gate
// on it, so it must stay a real Jellyfin release, not Navidrome's own version. 10.9+ is required
// for Feishin to use the server lyrics endpoint.
const jellyfinVersion = "10.9.11"
func (api *Router) serverName() string {
if conf.Server.Jellyfin.ServerName != "" {
return conf.Server.Jellyfin.ServerName
}
return fmt.Sprintf("Navidrome %s", consts.Version)
}
// serverID returns a stable Id that survives restarts, get-or-created in the Property table.
// Jellyfin clients cache ServerId across sessions, so a per-process value would break
// re-authentication. api.ds is nil only in unit tests; New() always sets it.
//
// The mutex serializes first-boot resolution so concurrent requests can't persist different
// UUIDs. Only a successful read or persisted id is cached; a transient failure yields a
// temporary id and retries on the next request rather than pinning a value.
func (api *Router) serverID(ctx context.Context) string {
api.serverIDMu.Lock()
defer api.serverIDMu.Unlock()
if api.serverIDVal != "" {
return api.serverIDVal
}
if api.ds == nil {
api.serverIDVal = newServerID()
return api.serverIDVal
}
id, err := api.ds.Property(ctx).Get(consts.JellyfinServerIDKey)
switch {
case errors.Is(err, model.ErrNotFound):
id = newServerID()
if err := api.ds.Property(ctx).Put(consts.JellyfinServerIDKey, id); err != nil {
log.Error(ctx, "Jellyfin API: could not persist server id", err)
return id
}
case err != nil:
log.Error(ctx, "Jellyfin API: could not read server id", err)
return newServerID()
}
// Ids persisted before this change are dashed; normalize on read rather than rewriting the DB.
api.serverIDVal = strings.ReplaceAll(id, "-", "")
return api.serverIDVal
}
// newServerID returns a UUID in Jellyfin's no-dash GUID form (Guid.ToString("N")).
func newServerID() string {
u := uuid.New()
return hex.EncodeToString(u[:])
}
func (api *Router) publicInfo(r *http.Request) dto.PublicSystemInfo {
return dto.PublicSystemInfo{
LocalAddress: localAddress(r),
ServerName: api.serverName(),
Version: jellyfinVersion,
ProductName: "Jellyfin Server",
Id: api.serverID(r.Context()),
StartupWizardCompleted: true,
}
}
// localAddress reconstructs the base URL the client used (scheme/host from the request, honoring
// X-Forwarded-* headers, plus the mount path), advertised as LocalAddress. Jellify adopts it as
// its server base URL; without it its SDK api instance is undefined and sign-in crashes.
func localAddress(r *http.Request) string {
scheme, host := server.ServerAddress(r)
return scheme + "://" + host + path.Join(conf.Server.BasePath, consts.URLPathJellyfinAPI)
}
func (api *Router) getPublicSystemInfo(w http.ResponseWriter, r *http.Request) {
api.ok(w, r, api.publicInfo(r))
}
func (api *Router) getSystemInfo(w http.ResponseWriter, r *http.Request) {
api.ok(w, r, dto.SystemInfo{
PublicSystemInfo: api.publicInfo(r),
SupportsLibraryMonitor: true,
})
}
// ping answers /System/Ping with a bare plain-text server name (not JSON-quoted): Jellyfin's
// server does this and clients parse the raw body.
func (api *Router) ping(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(api.serverName()))
}
// getEndpointInfo answers /System/Endpoint, which Finamp's connection test uses to pick between a
// dual-connection setup's addresses; a missing IsInNetwork reads to it as "not a Jellyfin server".
func (api *Router) getEndpointInfo(w http.ResponseWriter, r *http.Request) {
remote := remoteIP(r)
api.ok(w, r, dto.EndPointInfo{
IsLocal: isSameMachine(r, remote),
IsInNetwork: isInLocalNetwork(remote),
})
}
// isInLocalNetwork mirrors Jellyfin's default LAN set (NetworkManager.UpdateSettings with no
// LocalNetworkSubnets configured): loopback, the RFC 1918 ranges, fc00::/7 and fe80::/10.
func isInLocalNetwork(ip netip.Addr) bool {
return ip.IsLoopback() || ip.IsPrivate() || (ip.Is6() && ip.IsLinkLocalUnicast())
}
// isSameMachine mirrors Jellyfin's HttpContext.IsLocal(): the caller shares the connection's local
// address. The local address is missing in tests and unreliable behind a proxy, so fall back to loopback.
func isSameMachine(r *http.Request, remote netip.Addr) bool {
local, ok := r.Context().Value(http.LocalAddrContextKey).(net.Addr)
if !ok {
return remote.IsLoopback()
}
return parseIP(local.String()) == remote
}
// remoteIP parses RemoteAddr, which realIPMiddleware may have rewritten to a bare client IP.
func remoteIP(r *http.Request) netip.Addr {
return parseIP(r.RemoteAddr)
}
func parseIP(addr string) netip.Addr {
if h, _, err := net.SplitHostPort(addr); err == nil {
addr = h
}
ip, err := netip.ParseAddr(addr)
if err != nil {
return netip.Addr{}
}
return ip.Unmap()
}
func (api *Router) quickConnectEnabled(w http.ResponseWriter, r *http.Request) {
api.ok(w, r, false)
}