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

240 lines
10 KiB
Go

package jellyfin
import (
"encoding/json"
"net/http"
"sync"
"time"
"github.com/go-chi/chi/v5"
"golang.org/x/sync/singleflight"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/core"
"github.com/navidrome/navidrome/core/artwork"
"github.com/navidrome/navidrome/core/external"
"github.com/navidrome/navidrome/core/lyrics"
"github.com/navidrome/navidrome/core/playlists"
"github.com/navidrome/navidrome/core/scrobbler"
"github.com/navidrome/navidrome/core/sonic"
"github.com/navidrome/navidrome/core/stream"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/server"
"github.com/navidrome/navidrome/server/events"
"github.com/navidrome/navidrome/server/jellyfin/dto"
"github.com/navidrome/navidrome/utils/cache"
)
type Router struct {
http.Handler
ds model.DataStore
artwork artwork.Artwork
streamer stream.MediaStreamer
transcodeDecider stream.TranscodeDecider
players core.Players
scrobbler scrobbler.PlayTracker
playlists playlists.Playlists
provider external.Provider
sonic sonic.Engine
lyrics lyrics.Lyrics
broker events.Broker
lyricsCache cache.SimpleCache[string, model.LyricList]
similarFlight singleflight.Group
serverIDMu sync.Mutex
serverIDVal string
}
func New(ds model.DataStore, artwork artwork.Artwork, streamer stream.MediaStreamer,
transcodeDecider stream.TranscodeDecider, players core.Players,
scrobbler scrobbler.PlayTracker, playlists playlists.Playlists, provider external.Provider,
sonicSvc sonic.Engine, lyricsSvc lyrics.Lyrics, broker events.Broker) *Router {
r := &Router{
ds: ds, artwork: artwork, streamer: streamer, transcodeDecider: transcodeDecider,
players: players, scrobbler: scrobbler, playlists: playlists, provider: provider,
sonic: sonicSvc, lyrics: lyricsSvc, broker: broker,
lyricsCache: cache.NewSimpleCache[string, model.LyricList](cache.Options{
SizeLimit: 1000,
DefaultTTL: 5 * time.Minute,
}),
}
r.Handler = r.routes()
return r
}
func (api *Router) routes() http.Handler {
inner := chi.NewRouter()
// Read query params case-insensitively, like real Jellyfin. Must precede all routes so every
// handler and the api_key check see folded keys.
inner.Use(normalizeQueryKeys)
// Routes are lowercase; caseInsensitivePaths lowercases the request path. Keep new routes lowercase.
// Public (no auth): handshake + login.
inner.Get("/system/info/public", api.getPublicSystemInfo)
inner.Get("/system/ping", api.ping)
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-client throttle when one is configured.
if conf.Server.AuthRequestLimit > 0 {
limiter := server.ClientIPRateLimiter(conf.Server.AuthRequestLimit, conf.Server.AuthWindowLength)
inner.With(limiter).Post("/users/authenticatebyname", api.authenticateByName)
} else {
inner.Post("/users/authenticatebyname", api.authenticateByName)
}
inner.Get("/users/public", api.getPublicUsers)
// Images are intentionally public: artwork isn't sensitive, matching Jellyfin's image handling.
// Bound concurrency like Subsonic's getCoverArt: image decode/resize is CPU- and memory-heavy,
// and an unbounded burst (a client fetching artwork across a large library) can exhaust memory.
inner.Group(func(r chi.Router) {
r.Use(server.ThrottleBacklog(conf.Server.DevArtworkMaxRequests, conf.Server.DevArtworkThrottleBacklogLimit,
conf.Server.DevArtworkThrottleBacklogTimeout))
r.Get("/items/{itemId}/images/{type}", api.getItemImage)
r.Get("/items/{itemId}/images/{type}/{index}", api.getItemImage)
})
inner.Group(func(r chi.Router) {
r.Use(api.authenticate)
// Register/refresh the calling device as a player on every authenticated request, like
// Subsonic's getPlayer, so Jellyfin clients show up in the players list (and scrobbling has a
// player) even before the first playback report.
r.Use(api.withPlayer)
r.Get("/system/info", api.getSystemInfo)
r.Get("/system/endpoint", api.getEndpointInfo)
r.Get("/userviews", api.getUserViews)
r.Get("/users/{userId}/views", api.getUserViews)
r.Get("/users/me", api.getCurrentUser)
r.Get("/users/{userId}", api.getCurrentUser)
// Cursor-backed collections: each streams straight from the DB, holding a connection for the
// whole client-paced response, so enough slow clients would take the entire pool and stall the
// scanner, scrobbles and the UI. Cap them at half the pool (see conf.MaxOpenConns); excess
// requests queue rather than fail.
r.Group(func(r chi.Router) {
r.Use(throttleStreams(conf.Server.Jellyfin.MaxConcurrentStreams))
r.Get("/items", api.getItems)
r.Get("/users/{userId}/items", api.getItems)
r.Get("/items/latest", api.getLatest)
r.Get("/users/{userId}/items/latest", api.getLatest)
r.Get("/artists", api.getArtists)
r.Get("/artists/albumartists", api.getAlbumArtists)
r.Get("/playlists/{playlistId}/items", api.getPlaylistItems)
})
r.Get("/items/{itemId}", api.getItem)
r.Get("/users/{userId}/items/{itemId}", api.getItem)
r.Delete("/items/{itemId}", api.deleteItem)
// /UserFavoriteItems is the current @jellyfin/sdk spelling (Jellify); the
// /Users/{userId}/FavoriteItems form is the legacy one Finamp still uses.
r.Post("/userfavoriteitems/{itemId}", api.markFavorite)
r.Delete("/userfavoriteitems/{itemId}", api.unmarkFavorite)
r.Post("/users/{userId}/favoriteitems/{itemId}", api.markFavorite)
r.Delete("/users/{userId}/favoriteitems/{itemId}", api.unmarkFavorite)
r.Post("/users/{userId}/items/{itemId}/rating", api.setRating)
r.Delete("/users/{userId}/items/{itemId}/rating", api.removeRating)
// Per-item play/favorite/rating state. Jellify uses the /UserItems form;
// /Users/{userId}/Items is the legacy spelling.
r.Get("/useritems/{itemId}/userdata", api.getUserItemData)
r.Get("/users/{userId}/items/{itemId}/userdata", api.getUserItemData)
r.Get("/artists/{itemId}/similar", api.getSimilarArtists)
r.Get("/items/{itemId}/similar", api.getSimilarItems)
r.Get("/albums/{itemId}/similar", api.getSimilarAlbums)
r.Get("/items/{itemId}/instantmix", api.getInstantMix)
r.Get("/genres", api.getGenres)
r.Get("/musicgenres", api.getGenres)
r.Get("/studios", api.getStudios)
r.Get("/items/filters", api.getQueryFiltersLegacy)
r.Post("/playlists", api.createPlaylist)
r.Get("/playlists/{playlistId}", api.getPlaylist)
r.Post("/playlists/{playlistId}", api.updatePlaylist)
r.Post("/playlists/{playlistId}/items", api.addToPlaylist)
r.Delete("/playlists/{playlistId}/items", api.removeFromPlaylist)
r.Get("/playlists/{playlistId}/users", api.getPlaylistUsers)
r.Get("/playlists/{playlistId}/users/{userId}", api.getPlaylistUser)
// Cover upload/delete: only playlists are writable (see postItemImage); the GET routes
// above stay public.
r.Post("/items/{itemId}/images/{type}", api.postItemImage)
r.Delete("/items/{itemId}/images/{type}", api.deleteItemImage)
r.Get("/audio/{itemId}/stream", api.streamAudio)
r.Get("/audio/{itemId}/stream.{container}", api.streamAudio)
r.Get("/audio/{itemId}/universal", api.streamAudio)
r.Get("/audio/{itemId}/main.m3u8", api.streamHls)
r.Get("/items/{itemId}/playbackinfo", api.getPlaybackInfo)
r.Post("/items/{itemId}/playbackinfo", api.getPlaybackInfo)
r.Get("/audio/{itemId}/lyrics", api.getLyrics)
// Direct-file endpoints: some clients (Finamp's just_audio) fetch here instead of
// /Audio/{id}/stream; /Download reuses the direct-play handler as Jellyfin serves the same file.
r.Get("/items/{itemId}/file", api.streamFile)
r.Get("/items/{itemId}/download", api.streamFile)
r.Post("/sessions/playing", api.reportPlaybackStart)
r.Post("/sessions/playing/progress", api.reportPlaybackProgress)
r.Post("/sessions/playing/stopped", api.reportPlaybackStopped)
r.Post("/sessions/capabilities", api.postCapabilities)
r.Post("/sessions/capabilities/full", api.postCapabilities)
// Real-time clients (e.g. Finamp) open this right after login; without it they 404-loop-reconnect.
r.Get("/socket", api.handleSocket)
r.Get("/audiomuseai/info", api.audioMuseInfo)
r.Get("/audiomuseai/health", api.audioMuseHealth)
r.Get("/audiomuseai/similar_tracks", api.audioMuseSimilarTracks)
r.Get("/audiomuseai/find_path", api.audioMuseFindPath)
})
// Logged at Debug, not Warn/Error: clients probing for optional/legacy endpoints is expected
// traffic, and this just surfaces what's missing.
inner.NotFound(api.notFound)
inner.MethodNotAllowed(api.notFound)
// Real Jellyfin clients route case-insensitively; chi does not.
return caseInsensitivePaths(inner)
}
// ok writes payload as JSON — the single entry point for every handler. Collections are routed to
// the streaming writer, so callers needn't know whether theirs is cursor-backed. ServerId is stamped
// on any item(s): real Jellyfin always sets it, and it's constant per request.
//
// Only /Items/Latest bypasses this, for its bare-array shape (see writeItemsArray).
func (api *Router) ok(w http.ResponseWriter, r *http.Request, payload any) {
switch p := payload.(type) {
case itemsResult:
api.writeItems(w, r, p)
return
case dto.QueryResult:
api.writeItems(w, r, materialized(p))
return
case dto.BaseItemDto:
p.ServerId = api.serverID(r.Context())
payload = p
}
w.Header().Set("Content-Type", "application/json; charset=utf-8")
if err := json.NewEncoder(w).Encode(payload); err != nil {
log.Error(r.Context(), "Jellyfin API: error encoding response", err)
}
}
// notFound handles unmatched routes and unsupported methods, logging them so unimplemented
// endpoints surface instead of returning chi's default plain-text 404/405.
func (api *Router) notFound(w http.ResponseWriter, r *http.Request) {
log.Debug(r.Context(), "Jellyfin API: unhandled route", "method", r.Method, "path", r.URL.Path)
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(http.StatusNotFound)
_, _ = w.Write([]byte(`{}`))
}
// internalError logs the real error and writes a generic 500, so internal detail (ffmpeg output,
// file paths) never reaches the client.
func (api *Router) internalError(w http.ResponseWriter, r *http.Request, err error) {
log.Error(r.Context(), "Jellyfin API: internal error", "method", r.Method, "path", r.URL.Path, err)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
}