From 7cc59f095c6ddaad66e0bed26eeb757a3d0998de Mon Sep 17 00:00:00 2001 From: soldosluka857 Date: Sat, 30 May 2026 07:08:29 +0000 Subject: [PATCH] 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> --- internal/handler/emby.go | 14 ++++- internal/handler/handler.go | 16 ++++-- internal/service/auth.go | 27 +++++++++ internal/service/auth_user_limits_test.go | 34 +++++++++++ internal/service/image_proxy.go | 70 +++++++++++++++++------ internal/service/image_proxy_test.go | 58 +++++++++++++++++++ internal/service/service.go | 21 ++++++- 7 files changed, 215 insertions(+), 25 deletions(-) diff --git a/internal/handler/emby.go b/internal/handler/emby.go index d3547b7..6e02e96 100644 --- a/internal/handler/emby.go +++ b/internal/handler/emby.go @@ -207,8 +207,16 @@ func embyAuthByNameHandler(svc *service.Container) gin.HandlerFunc { return } userPayload, _ := svc.Emby.FindUser(c.Request.Context(), resp.User.ID) + // Emby/Jellyfin 客户端没有 refresh token 机制:它们把这里返回的 + // AccessToken 长期保存并反复使用。若返回 60 分钟的普通 access + // token,客户端每小时就会掉登录、无法播放、媒体库无法刷新。因此 + // 签发长期令牌(IssueEmbyToken)匹配 Emby 持久化令牌语义。 + accessToken := resp.Tokens.AccessToken + if longLived, err := svc.Auth.IssueEmbyToken(resp.User); err == nil && longLived != "" { + accessToken = longLived + } c.JSON(http.StatusOK, gin.H{ - "AccessToken": resp.Tokens.AccessToken, + "AccessToken": accessToken, "ServerId": "mediastation-go-001", "User": userPayload, "SessionInfo": gin.H{ @@ -729,7 +737,9 @@ func registerEmbyRoutes(r *gin.Engine, jwtSecret string, svc *service.Container) grp.HEAD(path, embyPingHandler(svc)) grp.POST(path, embyPingHandler(svc)) } - embyLoginLimiter := middleware.NewRateLimiter(10, 1*time.Minute) + // 30/min per IP: many Emby clients sit behind a single NAT/reverse-proxy + // IP, so a low limit would throttle legitimate logins into 429s. + embyLoginLimiter := middleware.NewRateLimiter(30, 1*time.Minute) for _, path := range []string{"/Users/AuthenticateByName", "/users/authenticatebyname"} { grp.POST(path, middleware.RateLimit(embyLoginLimiter), embyAuthByNameHandler(svc)) } diff --git a/internal/handler/handler.go b/internal/handler/handler.go index 45c072b..731ae2d 100644 --- a/internal/handler/handler.go +++ b/internal/handler/handler.go @@ -25,18 +25,24 @@ func Register(r *gin.Engine, cfg *config.Config, log *zap.Logger, svc *service.C // Telegram Bot webhook — called by Telegram servers, no auth. api.POST("/telegram/webhook", telegramWebhookHandler(svc)) - // Rate limiter for auth endpoints: 10 attempts per minute per IP. - authLimiter := middleware.NewRateLimiter(10, 1*time.Minute) + // Rate limiter for credential endpoints (login/register): brute-force + // protection. 30/min per IP tolerates many users behind a single NAT + // or reverse-proxy IP while still throttling password guessing. + authLimiter := middleware.NewRateLimiter(30, 1*time.Minute) // Public auth. auth := api.Group("/auth") - auth.Use(middleware.RateLimit(authLimiter)) { - auth.POST("/login", loginHandler(svc)) - auth.POST("/register", registerHandler(svc)) + auth.POST("/login", middleware.RateLimit(authLimiter), loginHandler(svc)) + auth.POST("/register", middleware.RateLimit(authLimiter), registerHandler(svc)) // /auth/refresh 用 RefreshHandler.RefreshToken:它从 body 读 // refresh_token 并签发新 access/refresh 对。旧的 refreshHandler // 依赖 AuthRequired 中间件,永远 401,因此弃用。 + // + // 刷新端点【不】做 IP 限流:刷新本身就是防止掉登录的机制,且已 + // 由一次性轮换的 refresh token 强校验。若按 IP 限流,多个用户/ + // 标签页共用一个反代 IP 时会把正常刷新打成 429,反而导致频繁 + // 掉登录。 refreshHd := NewRefreshHandler(svc, log) auth.POST("/refresh", refreshHd.RefreshToken) } diff --git a/internal/service/auth.go b/internal/service/auth.go index ae827fc..69dd5bb 100644 --- a/internal/service/auth.go +++ b/internal/service/auth.go @@ -241,6 +241,33 @@ func (s *AuthService) IssueToken(u *model.User) (string, error) { return t.SignedString([]byte(s.cfg.Secrets.JWTSecret)) } +// EmbyTokenDuration 是第三方 Emby/Jellyfin 客户端访问令牌的有效期。 +// Emby 协议没有 refresh token 机制——客户端登录一次后把 AccessToken +// 长期保存并反复使用,直到用户主动登出。若给它们签发 60 分钟的普通 +// access token,客户端每小时就会掉登录、无法播放、媒体库无法刷新。 +// 因此为这些设备签发长期令牌(与 refresh token 一致的 30 天),匹配 +// Emby 持久化令牌的语义。 +const EmbyTokenDuration = 30 * 24 * time.Hour + +// IssueEmbyToken 为第三方客户端(Emby/Jellyfin 兼容层)签发一个长期 +// JWT。它与普通 access token 使用相同的密钥与 Claims,因此沿用现有的 +// EmbyAuthRequired 校验逻辑,只是有效期更长。 +func (s *AuthService) IssueEmbyToken(u *model.User) (string, error) { + claims := Claims{ + UserID: u.ID, + Role: u.Role, + Tier: u.Tier, + RegisteredClaims: jwt.RegisteredClaims{ + IssuedAt: jwt.NewNumericDate(time.Now()), + ExpiresAt: jwt.NewNumericDate(time.Now().Add(EmbyTokenDuration)), + Issuer: "mediastationgo", + Subject: u.ID, + }, + } + t := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) + return t.SignedString([]byte(s.cfg.Secrets.JWTSecret)) +} + // RefreshTokens 使用刷新令牌获取新的令牌对。 func (s *AuthService) RefreshTokens(ctx context.Context, refreshToken string) (*TokenPair, error) { return s.tokenSvc.Refresh(ctx, refreshToken) diff --git a/internal/service/auth_user_limits_test.go b/internal/service/auth_user_limits_test.go index 370b1e7..36e8d29 100644 --- a/internal/service/auth_user_limits_test.go +++ b/internal/service/auth_user_limits_test.go @@ -5,8 +5,10 @@ import ( "errors" "fmt" "testing" + "time" "github.com/glebarez/sqlite" + "github.com/golang-jwt/jwt/v5" "go.uber.org/zap" "gorm.io/gorm" @@ -190,3 +192,35 @@ func TestDefaultAdminCannotBeDemoted(t *testing.T) { t.Fatal("expected default admin demotion to be rejected") } } + +// TestIssueEmbyTokenIsLongLived verifies the Emby/Jellyfin compatibility token +// outlives the 60-minute access token (Emby clients have no refresh mechanism, +// so a short token caused them to drop login / fail playback hourly), parses +// with the JWT secret, and carries the user's identity/role/tier. +func TestIssueEmbyTokenIsLongLived(t *testing.T) { + _, auth, _, _ := newAuthTestServices(t) + u := &model.User{Base: model.Base{ID: "u-emby"}, Username: "emby", Role: "user", Tier: "plus"} + + tok, err := auth.IssueEmbyToken(u) + if err != nil { + t.Fatalf("IssueEmbyToken: %v", err) + } + + claims := &Claims{} + parsed, err := jwt.ParseWithClaims(tok, claims, func(*jwt.Token) (interface{}, error) { + return []byte("test-secret"), nil + }) + if err != nil || !parsed.Valid { + t.Fatalf("token did not parse/validate: %v", err) + } + if claims.UserID != "u-emby" || claims.Role != "user" || claims.Tier != "plus" { + t.Fatalf("unexpected claims: %+v", claims) + } + ttl := time.Until(claims.ExpiresAt.Time) + if ttl <= AccessTokenDuration { + t.Fatalf("emby token ttl %v not longer than access token ttl %v", ttl, AccessTokenDuration) + } + if ttl < EmbyTokenDuration-time.Hour { + t.Fatalf("emby token ttl %v shorter than expected ~%v", ttl, EmbyTokenDuration) + } +} diff --git a/internal/service/image_proxy.go b/internal/service/image_proxy.go index 5f0cef0..7377ddc 100644 --- a/internal/service/image_proxy.go +++ b/internal/service/image_proxy.go @@ -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) diff --git a/internal/service/image_proxy_test.go b/internal/service/image_proxy_test.go index 3896335..930232e 100644 --- a/internal/service/image_proxy_test.go +++ b/internal/service/image_proxy_test.go @@ -36,3 +36,61 @@ func TestImageProxyServesLocalImagePath(t *testing.T) { t.Fatalf("body length = %d, want %d", rec.Body.Len(), len(transparent1x1PNG)) } } + +// TestImageProxyServesPosterUnderLibraryRoot verifies that sidecar posters +// stored under an arbitrary media library root (not the configured +// data/cache/movies dirs) are served rather than dropped to the placeholder. +// This is the regression that made web/Emby posters disappear. +func TestImageProxyServesPosterUnderLibraryRoot(t *testing.T) { + libDir := t.TempDir() + posterPath := filepath.Join(libDir, "Inception (2010)", "poster.png") + if err := os.MkdirAll(filepath.Dir(posterPath), 0o755); err != nil { + t.Fatal(err) + } + realPoster := []byte("THIS-IS-A-REAL-POSTER-NOT-THE-PLACEHOLDER") + if err := os.WriteFile(posterPath, realPoster, 0o644); err != nil { + t.Fatal(err) + } + + proxy := NewImageProxy(&config.Config{Cache: config.CacheConfig{CacheDir: filepath.Join(t.TempDir(), "cache")}}, zap.NewNop()) + + // Without a library-roots provider the poster lives outside every allowed + // root, so it must fall back to the transparent placeholder. + rec := httptest.NewRecorder() + if err := proxy.Serve(t.Context(), rec, httptest.NewRequest(http.MethodGet, "/api/img", nil), posterPath); err != nil { + t.Fatal(err) + } + if rec.Body.Len() != len(transparent1x1PNG) { + t.Fatalf("expected placeholder before provider set, got %d bytes", rec.Body.Len()) + } + + // Once the library root is known, the real poster bytes are served. + proxy.SetLibraryRootsProvider(func() []string { return []string{libDir} }) + rec = httptest.NewRecorder() + if err := proxy.Serve(t.Context(), rec, httptest.NewRequest(http.MethodGet, "/api/img", nil), posterPath); err != nil { + t.Fatal(err) + } + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200", rec.Code) + } + if got := rec.Body.Bytes(); string(got) != string(realPoster) { + t.Fatalf("served %q, want real poster bytes", string(got)) + } +} + +func TestIsPrivateHost(t *testing.T) { + blocked := []string{"127.0.0.1", "10.0.0.5", "192.168.1.10", "169.254.169.254", "0.0.0.0", "::1", ""} + for _, h := range blocked { + if !isPrivateHost(h) { + t.Errorf("isPrivateHost(%q) = false, want true (literal private/loopback IP)", h) + } + } + // Hostnames must NOT be blocked even though GFW DNS poisoning may resolve + // them to private/loopback IPs — blocking them broke legitimate posters. + allowed := []string{"image.tmdb.org", "lain.bgm.tv", "example.com", "8.8.8.8"} + for _, h := range allowed { + if isPrivateHost(h) { + t.Errorf("isPrivateHost(%q) = true, want false", h) + } + } +} diff --git a/internal/service/service.go b/internal/service/service.go index 665b3d6..59cc4c5 100644 --- a/internal/service/service.go +++ b/internal/service/service.go @@ -5,6 +5,7 @@ package service import ( "context" + "strings" "time" "go.uber.org/zap" @@ -131,6 +132,24 @@ func New(cfg *config.Config, log *zap.Logger, repos *repository.Container) *Cont downloads := NewDownloadService(log, repos, hub, organizer, siteSvc) subscription := NewSubscriptionService(cfg, log, repos, downloads, siteSvc, hub) + // 让图片代理把媒体库根目录视为可读的本地图片位置:海报/封面等 + // sidecar 资源就存放在这些(用户自定义、任意)目录下,否则会被 + // 路径白名单挡掉、退化成占位图导致前端图片不显示。 + imageProxy := NewImageProxy(cfg, log) + imageProxy.SetLibraryRootsProvider(func() []string { + libs, err := repos.Library.List(context.Background()) + if err != nil { + return nil + } + roots := make([]string, 0, len(libs)) + for _, l := range libs { + if strings.TrimSpace(l.Path) != "" { + roots = append(roots, l.Path) + } + } + return roots + }) + ctx, cancel := context.WithCancel(context.Background()) return &Container{ @@ -152,7 +171,7 @@ func New(cfg *config.Config, log *zap.Logger, repos *repository.Container) *Cont Scraper: scraper, Discover: discover, Playback: NewPlaybackService(log, repos), - ImageProxy: NewImageProxy(cfg, log), + ImageProxy: imageProxy, Watcher: watcher, Downloads: downloads, Subscription: subscription,