mirror of
https://github.com/navidrome/navidrome.git
synced 2026-10-30 15:07:56 +01:00
feat(api): restrict access to library and config endpoints to admin users
Signed-off-by: Deluan <deluan@navidrome.org>
This commit is contained in:
parent
403fe19e1a
commit
7dce0da557
5 changed files with 178 additions and 110 deletions
|
|
@ -1,109 +1,170 @@
|
|||
package nativeapi
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/conf/configtest"
|
||||
"github.com/navidrome/navidrome/consts"
|
||||
"github.com/navidrome/navidrome/core/auth"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/model/request"
|
||||
"github.com/navidrome/navidrome/server"
|
||||
"github.com/navidrome/navidrome/tests"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("getConfig", func() {
|
||||
var _ = Describe("Config API", func() {
|
||||
var ds model.DataStore
|
||||
var router http.Handler
|
||||
var adminUser, regularUser model.User
|
||||
|
||||
BeforeEach(func() {
|
||||
DeferCleanup(configtest.SetupConfig())
|
||||
conf.Server.DevUIShowConfig = true // Enable config endpoint for tests
|
||||
ds = &tests.MockDataStore{}
|
||||
auth.Init(ds)
|
||||
nativeRouter := New(ds, nil, nil, nil)
|
||||
router = server.JWTVerifier(nativeRouter)
|
||||
|
||||
// Create test users
|
||||
adminUser = model.User{
|
||||
ID: "admin-1",
|
||||
UserName: "admin",
|
||||
Name: "Admin User",
|
||||
IsAdmin: true,
|
||||
NewPassword: "adminpass",
|
||||
}
|
||||
regularUser = model.User{
|
||||
ID: "user-1",
|
||||
UserName: "regular",
|
||||
Name: "Regular User",
|
||||
IsAdmin: false,
|
||||
NewPassword: "userpass",
|
||||
}
|
||||
|
||||
// Store in mock datastore
|
||||
Expect(ds.User(context.TODO()).Put(&adminUser)).To(Succeed())
|
||||
Expect(ds.User(context.TODO()).Put(®ularUser)).To(Succeed())
|
||||
})
|
||||
|
||||
Context("when user is not admin", func() {
|
||||
It("returns unauthorized", func() {
|
||||
req := httptest.NewRequest("GET", "/config", nil)
|
||||
w := httptest.NewRecorder()
|
||||
ctx := request.WithUser(req.Context(), model.User{IsAdmin: false})
|
||||
Describe("GET /api/config", func() {
|
||||
Context("as admin user", func() {
|
||||
var adminToken string
|
||||
|
||||
getConfig(w, req.WithContext(ctx))
|
||||
BeforeEach(func() {
|
||||
var err error
|
||||
adminToken, err = auth.CreateToken(&adminUser)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
})
|
||||
|
||||
Expect(w.Code).To(Equal(http.StatusUnauthorized))
|
||||
})
|
||||
})
|
||||
It("returns config successfully", func() {
|
||||
req := createAuthenticatedConfigRequest(adminToken)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
Context("when user is admin", func() {
|
||||
It("returns config successfully", func() {
|
||||
req := httptest.NewRequest("GET", "/config", nil)
|
||||
w := httptest.NewRecorder()
|
||||
ctx := request.WithUser(req.Context(), model.User{IsAdmin: true})
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
getConfig(w, req.WithContext(ctx))
|
||||
Expect(w.Code).To(Equal(http.StatusOK))
|
||||
var resp configResponse
|
||||
Expect(json.Unmarshal(w.Body.Bytes(), &resp)).To(Succeed())
|
||||
Expect(resp.ID).To(Equal("config"))
|
||||
Expect(resp.ConfigFile).To(Equal(conf.Server.ConfigFile))
|
||||
Expect(resp.Config).ToNot(BeEmpty())
|
||||
})
|
||||
|
||||
Expect(w.Code).To(Equal(http.StatusOK))
|
||||
var resp configResponse
|
||||
Expect(json.Unmarshal(w.Body.Bytes(), &resp)).To(Succeed())
|
||||
Expect(resp.ID).To(Equal("config"))
|
||||
Expect(resp.ConfigFile).To(Equal(conf.Server.ConfigFile))
|
||||
Expect(resp.Config).ToNot(BeEmpty())
|
||||
It("redacts sensitive fields", func() {
|
||||
conf.Server.LastFM.ApiKey = "secretapikey123"
|
||||
conf.Server.Spotify.Secret = "spotifysecret456"
|
||||
conf.Server.PasswordEncryptionKey = "encryptionkey789"
|
||||
conf.Server.DevAutoCreateAdminPassword = "adminpassword123"
|
||||
conf.Server.Prometheus.Password = "prometheuspass"
|
||||
|
||||
req := createAuthenticatedConfigRequest(adminToken)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
Expect(w.Code).To(Equal(http.StatusOK))
|
||||
var resp configResponse
|
||||
Expect(json.Unmarshal(w.Body.Bytes(), &resp)).To(Succeed())
|
||||
|
||||
// Check LastFM.ApiKey (partially masked)
|
||||
lastfm, ok := resp.Config["LastFM"].(map[string]interface{})
|
||||
Expect(ok).To(BeTrue())
|
||||
Expect(lastfm["ApiKey"]).To(Equal("s*************3"))
|
||||
|
||||
// Check Spotify.Secret (partially masked)
|
||||
spotify, ok := resp.Config["Spotify"].(map[string]interface{})
|
||||
Expect(ok).To(BeTrue())
|
||||
Expect(spotify["Secret"]).To(Equal("s**************6"))
|
||||
|
||||
// Check PasswordEncryptionKey (fully masked)
|
||||
Expect(resp.Config["PasswordEncryptionKey"]).To(Equal("****"))
|
||||
|
||||
// Check DevAutoCreateAdminPassword (fully masked)
|
||||
Expect(resp.Config["DevAutoCreateAdminPassword"]).To(Equal("****"))
|
||||
|
||||
// Check Prometheus.Password (fully masked)
|
||||
prometheus, ok := resp.Config["Prometheus"].(map[string]interface{})
|
||||
Expect(ok).To(BeTrue())
|
||||
Expect(prometheus["Password"]).To(Equal("****"))
|
||||
})
|
||||
|
||||
It("handles empty sensitive values", func() {
|
||||
conf.Server.LastFM.ApiKey = ""
|
||||
conf.Server.PasswordEncryptionKey = ""
|
||||
|
||||
req := createAuthenticatedConfigRequest(adminToken)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
Expect(w.Code).To(Equal(http.StatusOK))
|
||||
var resp configResponse
|
||||
Expect(json.Unmarshal(w.Body.Bytes(), &resp)).To(Succeed())
|
||||
|
||||
// Check LastFM.ApiKey - should be preserved because it's sensitive
|
||||
lastfm, ok := resp.Config["LastFM"].(map[string]interface{})
|
||||
Expect(ok).To(BeTrue())
|
||||
Expect(lastfm["ApiKey"]).To(Equal(""))
|
||||
|
||||
// Empty sensitive values should remain empty - should be preserved because it's sensitive
|
||||
Expect(resp.Config["PasswordEncryptionKey"]).To(Equal(""))
|
||||
})
|
||||
})
|
||||
|
||||
It("redacts sensitive fields", func() {
|
||||
conf.Server.LastFM.ApiKey = "secretapikey123"
|
||||
conf.Server.Spotify.Secret = "spotifysecret456"
|
||||
conf.Server.PasswordEncryptionKey = "encryptionkey789"
|
||||
conf.Server.DevAutoCreateAdminPassword = "adminpassword123"
|
||||
conf.Server.Prometheus.Password = "prometheuspass"
|
||||
Context("as regular user", func() {
|
||||
var userToken string
|
||||
|
||||
req := httptest.NewRequest("GET", "/config", nil)
|
||||
w := httptest.NewRecorder()
|
||||
ctx := request.WithUser(req.Context(), model.User{IsAdmin: true})
|
||||
getConfig(w, req.WithContext(ctx))
|
||||
BeforeEach(func() {
|
||||
var err error
|
||||
userToken, err = auth.CreateToken(®ularUser)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
})
|
||||
|
||||
Expect(w.Code).To(Equal(http.StatusOK))
|
||||
var resp configResponse
|
||||
Expect(json.Unmarshal(w.Body.Bytes(), &resp)).To(Succeed())
|
||||
It("denies access with forbidden status", func() {
|
||||
req := createAuthenticatedConfigRequest(userToken)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
// Check LastFM.ApiKey (partially masked)
|
||||
lastfm, ok := resp.Config["LastFM"].(map[string]interface{})
|
||||
Expect(ok).To(BeTrue())
|
||||
Expect(lastfm["ApiKey"]).To(Equal("s*************3"))
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
// Check Spotify.Secret (partially masked)
|
||||
spotify, ok := resp.Config["Spotify"].(map[string]interface{})
|
||||
Expect(ok).To(BeTrue())
|
||||
Expect(spotify["Secret"]).To(Equal("s**************6"))
|
||||
|
||||
// Check PasswordEncryptionKey (fully masked)
|
||||
Expect(resp.Config["PasswordEncryptionKey"]).To(Equal("****"))
|
||||
|
||||
// Check DevAutoCreateAdminPassword (fully masked)
|
||||
Expect(resp.Config["DevAutoCreateAdminPassword"]).To(Equal("****"))
|
||||
|
||||
// Check Prometheus.Password (fully masked)
|
||||
prometheus, ok := resp.Config["Prometheus"].(map[string]interface{})
|
||||
Expect(ok).To(BeTrue())
|
||||
Expect(prometheus["Password"]).To(Equal("****"))
|
||||
Expect(w.Code).To(Equal(http.StatusForbidden))
|
||||
})
|
||||
})
|
||||
|
||||
It("handles empty sensitive values", func() {
|
||||
conf.Server.LastFM.ApiKey = ""
|
||||
conf.Server.PasswordEncryptionKey = ""
|
||||
Context("without authentication", func() {
|
||||
It("denies access with unauthorized status", func() {
|
||||
req := createUnauthenticatedConfigRequest("GET", "/config/", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
req := httptest.NewRequest("GET", "/config", nil)
|
||||
w := httptest.NewRecorder()
|
||||
ctx := request.WithUser(req.Context(), model.User{IsAdmin: true})
|
||||
getConfig(w, req.WithContext(ctx))
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
Expect(w.Code).To(Equal(http.StatusOK))
|
||||
var resp configResponse
|
||||
Expect(json.Unmarshal(w.Body.Bytes(), &resp)).To(Succeed())
|
||||
|
||||
// Check LastFM.ApiKey - should be preserved because it's sensitive
|
||||
lastfm, ok := resp.Config["LastFM"].(map[string]interface{})
|
||||
Expect(ok).To(BeTrue())
|
||||
Expect(lastfm["ApiKey"]).To(Equal(""))
|
||||
|
||||
// Empty sensitive values should remain empty - should be preserved because it's sensitive
|
||||
Expect(resp.Config["PasswordEncryptionKey"]).To(Equal(""))
|
||||
Expect(w.Code).To(Equal(http.StatusUnauthorized))
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -145,3 +206,21 @@ var _ = Describe("redactValue function", func() {
|
|||
Expect(redactValue("LastFM.ApiKey", "abcdefg")).To(Equal("a*****g"))
|
||||
})
|
||||
})
|
||||
|
||||
// Helper functions
|
||||
|
||||
func createAuthenticatedConfigRequest(token string) *http.Request {
|
||||
req := httptest.NewRequest(http.MethodGet, "/config/config", nil)
|
||||
req.Header.Set(consts.UIAuthorizationHeader, "Bearer "+token)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
return req
|
||||
}
|
||||
|
||||
func createUnauthenticatedConfigRequest(method, path string, body *bytes.Buffer) *http.Request {
|
||||
if body == nil {
|
||||
body = &bytes.Buffer{}
|
||||
}
|
||||
req := httptest.NewRequest(method, path, body)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
return req
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue