mirror of
https://github.com/navidrome/navidrome.git
synced 2026-10-14 13:27:09 +02:00
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.
157 lines
5.4 KiB
Go
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)
|
|
}
|