From 92c66178dee7ec7230cf9c0a9c5a37bbcb37915a Mon Sep 17 00:00:00 2001 From: Deluan Date: Sun, 5 Jul 2026 15:01:02 -0400 Subject: [PATCH] fix(jellyfin): reject empty login password, wire ServerName, add negative auth tests --- server/jellyfin/auth.go | 3 ++- server/jellyfin/auth_test.go | 14 +++++++++++++- server/jellyfin/dto/dto.go | 1 + server/jellyfin/middlewares_test.go | 29 ++++++++++++++++++++++++++++- 4 files changed, 44 insertions(+), 3 deletions(-) diff --git a/server/jellyfin/auth.go b/server/jellyfin/auth.go index 15d332f3e..c9b56df24 100644 --- a/server/jellyfin/auth.go +++ b/server/jellyfin/auth.go @@ -25,7 +25,7 @@ func (api *Router) authenticateByName(w http.ResponseWriter, r *http.Request) { // Navidrome stores recoverable passwords; this mirrors Subsonic's validateCredentials plaintext path. usr, err := api.ds.User(ctx).FindByUsernameWithPassword(body.Username) - if err != nil || usr.Password != body.Pw { + if body.Pw == "" || err != nil || usr.Password != body.Pw { log.Warn(ctx, "Jellyfin API: invalid login", "username", body.Username, "remoteAddr", r.RemoteAddr) http.Error(w, "Unauthorized", http.StatusUnauthorized) return @@ -49,6 +49,7 @@ func userToDto(u *model.User, serverName string) *dto.UserDto { return &dto.UserDto{ Name: u.UserName, Id: u.ID, + ServerName: serverName, HasPassword: true, HasConfiguredPassword: true, } diff --git a/server/jellyfin/auth_test.go b/server/jellyfin/auth_test.go index 6170c0d59..c998930d9 100644 --- a/server/jellyfin/auth_test.go +++ b/server/jellyfin/auth_test.go @@ -1,6 +1,7 @@ package jellyfin import ( + "context" "encoding/json" "net/http" "net/http/httptest" @@ -20,7 +21,7 @@ var _ = Describe("AuthenticateByName", func() { BeforeEach(func() { ds = &tests.MockDataStore{} auth.Init(ds) - ur := ds.User(nil).(*tests.MockedUserRepo) + ur := ds.User(context.Background()).(*tests.MockedUserRepo) Expect(ur.Put(&model.User{ID: "u1", UserName: "alice", NewPassword: "secret"})).To(Succeed()) api = &Router{ds: ds} }) @@ -48,4 +49,15 @@ var _ = Describe("AuthenticateByName", func() { api.authenticateByName(w, r) Expect(w.Code).To(Equal(http.StatusUnauthorized)) }) + + It("rejects an empty password even for a user with an empty stored password with 401", func() { + ur := ds.User(context.Background()).(*tests.MockedUserRepo) + Expect(ur.Put(&model.User{ID: "e", UserName: "empty", NewPassword: ""})).To(Succeed()) + + w := httptest.NewRecorder() + r := httptest.NewRequest("POST", "/Users/AuthenticateByName", + strings.NewReader(`{"Username":"empty","Pw":""}`)) + api.authenticateByName(w, r) + Expect(w.Code).To(Equal(http.StatusUnauthorized)) + }) }) diff --git a/server/jellyfin/dto/dto.go b/server/jellyfin/dto/dto.go index f836bbf2b..54743b73c 100644 --- a/server/jellyfin/dto/dto.go +++ b/server/jellyfin/dto/dto.go @@ -77,6 +77,7 @@ type QueryResult struct { type UserDto struct { Name string `json:"Name"` ServerId string `json:"ServerId,omitempty"` + ServerName string `json:"ServerName,omitempty"` Id string `json:"Id"` HasPassword bool `json:"HasPassword"` HasConfiguredPassword bool `json:"HasConfiguredPassword"` diff --git a/server/jellyfin/middlewares_test.go b/server/jellyfin/middlewares_test.go index 585743b9b..d8c62d555 100644 --- a/server/jellyfin/middlewares_test.go +++ b/server/jellyfin/middlewares_test.go @@ -1,6 +1,7 @@ package jellyfin import ( + "context" "net/http" "net/http/httptest" @@ -18,7 +19,7 @@ var _ = Describe("authenticate middleware", func() { BeforeEach(func() { ds = &tests.MockDataStore{} auth.Init(ds) - ur := ds.User(nil).(*tests.MockedUserRepo) + ur := ds.User(context.Background()).(*tests.MockedUserRepo) Expect(ur.Put(&model.User{ID: "u1", UserName: "alice", NewPassword: "secret"})).To(Succeed()) api = &Router{ds: ds} }) @@ -50,4 +51,30 @@ var _ = Describe("authenticate middleware", func() { api.authenticate(next).ServeHTTP(w, r) Expect(w.Code).To(Equal(http.StatusUnauthorized)) }) + + It("rejects a garbage token with 401 and does not call next", func() { + nextCalled := false + next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + nextCalled = true + w.WriteHeader(http.StatusOK) + }) + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/Items", nil) + r.Header.Set("X-Emby-Token", "not-a-jwt") + api.authenticate(next).ServeHTTP(w, r) + Expect(w.Code).To(Equal(http.StatusUnauthorized)) + Expect(nextCalled).To(BeFalse()) + }) + + It("rejects a valid token whose subject user does not exist with 401", func() { + next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) }) + t, err := auth.CreateToken(&model.User{ID: "x", UserName: "ghost"}) + Expect(err).ToNot(HaveOccurred()) + + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/Items", nil) + r.Header.Set("X-Emby-Token", t) + api.authenticate(next).ServeHTTP(w, r) + Expect(w.Code).To(Equal(http.StatusUnauthorized)) + }) })