fix(auth/images): long-lived Emby token, unthrottle refresh, serve library posters

Three regressions reported on third-party clients and the web UI:

- Third-party clients (Emby/Jellyfin) dropped login / could not play /
  could not refresh the library, roughly hourly. The Emby
  AuthenticateByName response returned the 60-minute access token, but
  Emby clients have no refresh mechanism and reuse the AccessToken until
  logout. Issue a long-lived (30d) token for the Emby compat layer via
  AuthService.IssueEmbyToken so device sessions persist.

- Web could be thrown back to login under load: /auth/refresh was inside
  the IP rate-limited /auth group, so multiple users/tabs behind one
  reverse-proxy/NAT IP exhausted the budget and refresh failed -> logout.
  Only login/register are rate-limited now (raised to 30/min for shared
  IPs); refresh is excluded (already protected by a one-time refresh token).

- Posters/images stopped displaying on the web home and other pages
  (refresh did not help). The SSRF/path hardening (a) blocked the image
  proxy whenever a hostname *resolved* to a private IP, which happens
  under GFW DNS poisoning of public CDNs like image.tmdb.org, and (b)
  restricted local image reads to data/cache/movies/tv/anime dirs only,
  dropping sidecar posters stored under arbitrary per-library roots to a
  placeholder. isPrivateHost now only blocks literal private/loopback IPs
  (real SSRF vectors) and ImageProxy also allows reads under configured
  library roots.

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
soldosluka857
2026-05-30 07:08:29 +00:00
committed by Shuke
parent 93c9cb7dfb
commit 7cc59f095c
7 changed files with 215 additions and 25 deletions
+53 -17
View File
@@ -73,6 +73,15 @@ type ImageProxy struct {
client *http.Client
cacheDir string
mu sync.Mutex
// libraryRootsFn returns the configured media library roots so that
// sidecar poster/artwork files stored alongside media (under arbitrary
// per-library paths) are allowed by isAllowedLocalPath. It is provided
// by the service container after construction and may be nil in tests.
libraryRootsFn func() []string
libRootsMu sync.Mutex
libRootsCache []string
libRootsAt time.Time
}
// NewImageProxy is the constructor.
@@ -89,6 +98,31 @@ func NewImageProxy(cfg *config.Config, log *zap.Logger) *ImageProxy {
}
}
// SetLibraryRootsProvider injects a callback that returns the current set of
// media library root directories. Sidecar posters live under these roots
// (which are arbitrary, user-defined, and not necessarily under the
// configured movies/tv/anime dirs), so they must be treated as allowed
// local-image locations.
func (p *ImageProxy) SetLibraryRootsProvider(fn func() []string) {
p.libraryRootsFn = fn
}
// libraryRoots returns the cached library roots, refreshing at most every
// 30 seconds to avoid a DB hit per image request (posters load in bulk).
func (p *ImageProxy) libraryRoots() []string {
if p.libraryRootsFn == nil {
return nil
}
p.libRootsMu.Lock()
defer p.libRootsMu.Unlock()
if p.libRootsCache != nil && time.Since(p.libRootsAt) < 30*time.Second {
return p.libRootsCache
}
p.libRootsCache = p.libraryRootsFn()
p.libRootsAt = time.Now()
return p.libRootsCache
}
// validateURL parses raw and ensures the scheme is http/https and the
// target host is not a private/loopback/link-local address (SSRF guard).
func (p *ImageProxy) validateURL(raw string) (*url.URL, error) {
@@ -109,9 +143,16 @@ func (p *ImageProxy) validateURL(raw string) (*url.URL, error) {
return u, nil
}
// isPrivateHost returns true if host resolves to a loopback, private, or
// link-local address. This blocks SSRF attacks that try to reach internal
// services (e.g. cloud metadata at 169.254.169.254).
// isPrivateHost returns true only when host is a *literal* loopback, private,
// link-local or unspecified IP address. This blocks the obvious SSRF vectors
// (e.g. http://127.0.0.1/… or the cloud metadata IP 169.254.169.254) while
// NOT blocking hostnames.
//
// We deliberately do not resolve hostnames here: under GFW DNS poisoning,
// public image CDNs such as image.tmdb.org are frequently resolved to
// loopback/private/bogus IPs. Blocking on resolved addresses would therefore
// wrongly drop legitimate posters for exactly the users this proxy exists to
// serve, which is what caused posters to stop displaying.
func isPrivateHost(host string) bool {
if host == "" {
return true
@@ -120,24 +161,19 @@ func isPrivateHost(host string) bool {
if ip != nil {
return ip.IsLoopback() || ip.IsPrivate() || ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() || ip.IsUnspecified()
}
addrs, err := net.LookupHost(host)
if err != nil {
return false
}
for _, addr := range addrs {
resolved := net.ParseIP(addr)
if resolved != nil && (resolved.IsLoopback() || resolved.IsPrivate() || resolved.IsLinkLocalUnicast() || resolved.IsLinkLocalMulticast() || resolved.IsUnspecified()) {
return true
}
}
return false
}
// isAllowedLocalPath restricts local file reads to the data directory and
// cache directory to prevent arbitrary file read via path traversal.
// isAllowedLocalPath restricts local file reads to known-safe roots to
// prevent arbitrary file reads via path traversal. Allowed roots are the
// data dir, cache dir, the configured movies/tv/anime dirs, and — crucially —
// every configured media library root, because sidecar posters/artwork are
// stored alongside media under those (arbitrary, user-defined) paths.
func (p *ImageProxy) isAllowedLocalPath(abs string) bool {
for _, root := range []string{p.cfg.App.DataDir, p.cfg.Cache.CacheDir, p.cfg.Media.MoviesDir, p.cfg.Media.TVDir, p.cfg.Media.AnimeDir} {
if root == "" {
roots := []string{p.cfg.App.DataDir, p.cfg.Cache.CacheDir, p.cfg.Media.MoviesDir, p.cfg.Media.TVDir, p.cfg.Media.AnimeDir}
roots = append(roots, p.libraryRoots()...)
for _, root := range roots {
if strings.TrimSpace(root) == "" {
continue
}
rootAbs, err := filepath.Abs(root)