mirror of
https://github.com/navidrome/navidrome.git
synced 2026-03-04 06:35:52 +00:00
481 lines
16 KiB
Go
481 lines
16 KiB
Go
//go:build !windows
|
|
|
|
package plugins
|
|
|
|
import (
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"io"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
|
|
"github.com/navidrome/navidrome/conf"
|
|
"github.com/navidrome/navidrome/conf/configtest"
|
|
"github.com/navidrome/navidrome/model"
|
|
"github.com/navidrome/navidrome/model/request"
|
|
"github.com/navidrome/navidrome/tests"
|
|
. "github.com/onsi/ginkgo/v2"
|
|
. "github.com/onsi/gomega"
|
|
)
|
|
|
|
// fakeNativeAuth is a mock native auth middleware that authenticates by looking up
|
|
// the "X-Test-User" header and setting the user in the context.
|
|
func fakeNativeAuth(ds model.DataStore) func(next http.Handler) http.Handler {
|
|
return func(next http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
username := r.Header.Get("X-Test-User")
|
|
if username == "" {
|
|
http.Error(w, "Not authenticated", http.StatusUnauthorized)
|
|
return
|
|
}
|
|
user, err := ds.User(r.Context()).FindByUsername(username)
|
|
if err != nil {
|
|
http.Error(w, "Not authenticated", http.StatusUnauthorized)
|
|
return
|
|
}
|
|
ctx := request.WithUser(r.Context(), *user)
|
|
ctx = request.WithUsername(ctx, user.UserName)
|
|
next.ServeHTTP(w, r.WithContext(ctx))
|
|
})
|
|
}
|
|
}
|
|
|
|
// fakeSubsonicAuth is a mock subsonic auth that validates by looking up
|
|
// the "u" query parameter.
|
|
func fakeSubsonicAuth(ds model.DataStore, r *http.Request) (*model.User, error) {
|
|
username := r.URL.Query().Get("u")
|
|
if username == "" {
|
|
return nil, model.ErrInvalidAuth
|
|
}
|
|
user, err := ds.User(r.Context()).FindByUsername(username)
|
|
if err != nil {
|
|
return nil, model.ErrInvalidAuth
|
|
}
|
|
return user, nil
|
|
}
|
|
|
|
var _ = Describe("HTTP Endpoint Handler", Ordered, func() {
|
|
var (
|
|
manager *Manager
|
|
tmpDir string
|
|
userRepo *tests.MockedUserRepo
|
|
dataStore *tests.MockDataStore
|
|
router http.Handler
|
|
)
|
|
|
|
BeforeAll(func() {
|
|
var err error
|
|
tmpDir, err = os.MkdirTemp("", "http-endpoint-test-*")
|
|
Expect(err).ToNot(HaveOccurred())
|
|
|
|
// Copy all test plugins
|
|
for _, pluginName := range []string{"test-http-endpoint", "test-http-endpoint-public", "test-http-endpoint-native"} {
|
|
srcPath := filepath.Join(testdataDir, pluginName+PackageExtension)
|
|
destPath := filepath.Join(tmpDir, pluginName+PackageExtension)
|
|
data, err := os.ReadFile(srcPath)
|
|
Expect(err).ToNot(HaveOccurred())
|
|
err = os.WriteFile(destPath, data, 0600)
|
|
Expect(err).ToNot(HaveOccurred())
|
|
}
|
|
|
|
// Setup config
|
|
DeferCleanup(configtest.SetupConfig())
|
|
conf.Server.Plugins.Enabled = true
|
|
conf.Server.Plugins.Folder = tmpDir
|
|
conf.Server.Plugins.AutoReload = false
|
|
conf.Server.CacheFolder = filepath.Join(tmpDir, "cache")
|
|
|
|
// Setup mock data store
|
|
userRepo = tests.CreateMockUserRepo()
|
|
dataStore = &tests.MockDataStore{MockedUser: userRepo}
|
|
|
|
// Add test users
|
|
_ = userRepo.Put(&model.User{
|
|
ID: "user1",
|
|
UserName: "testuser",
|
|
Name: "Test User",
|
|
IsAdmin: false,
|
|
})
|
|
_ = userRepo.Put(&model.User{
|
|
ID: "admin1",
|
|
UserName: "adminuser",
|
|
Name: "Admin User",
|
|
IsAdmin: true,
|
|
})
|
|
|
|
// Build enabled plugins list
|
|
var enabledPlugins model.Plugins
|
|
for _, pluginName := range []string{"test-http-endpoint", "test-http-endpoint-public", "test-http-endpoint-native"} {
|
|
pluginPath := filepath.Join(tmpDir, pluginName+PackageExtension)
|
|
data, err := os.ReadFile(pluginPath)
|
|
Expect(err).ToNot(HaveOccurred())
|
|
hash := sha256.Sum256(data)
|
|
hashHex := hex.EncodeToString(hash[:])
|
|
|
|
enabledPlugins = append(enabledPlugins, model.Plugin{
|
|
ID: pluginName,
|
|
Path: pluginPath,
|
|
SHA256: hashHex,
|
|
Enabled: true,
|
|
AllUsers: true,
|
|
})
|
|
}
|
|
|
|
// Setup mock plugin repo
|
|
mockPluginRepo := dataStore.Plugin(GinkgoT().Context()).(*tests.MockPluginRepo)
|
|
mockPluginRepo.Permitted = true
|
|
mockPluginRepo.SetData(enabledPlugins)
|
|
|
|
// Create and start manager
|
|
manager = &Manager{
|
|
plugins: make(map[string]*plugin),
|
|
ds: dataStore,
|
|
metrics: noopMetricsRecorder{},
|
|
subsonicRouter: http.NotFoundHandler(),
|
|
}
|
|
err = manager.Start(GinkgoT().Context())
|
|
Expect(err).ToNot(HaveOccurred())
|
|
|
|
// Create the endpoint router with fake auth functions
|
|
router = NewEndpointRouter(manager, dataStore, fakeSubsonicAuth, fakeNativeAuth)
|
|
|
|
DeferCleanup(func() {
|
|
_ = manager.Stop()
|
|
_ = os.RemoveAll(tmpDir)
|
|
})
|
|
})
|
|
|
|
Describe("Plugin Loading", func() {
|
|
It("loads the authenticated endpoint plugin", func() {
|
|
manager.mu.RLock()
|
|
p := manager.plugins["test-http-endpoint"]
|
|
manager.mu.RUnlock()
|
|
|
|
Expect(p).ToNot(BeNil())
|
|
Expect(p.manifest.Name).To(Equal("Test HTTP Endpoint Plugin"))
|
|
Expect(p.manifest.Permissions.Endpoints).ToNot(BeNil())
|
|
Expect(string(p.manifest.Permissions.Endpoints.Auth)).To(Equal("subsonic"))
|
|
Expect(hasCapability(p.capabilities, CapabilityHTTPEndpoint)).To(BeTrue())
|
|
})
|
|
|
|
It("loads the native auth endpoint plugin", func() {
|
|
manager.mu.RLock()
|
|
p := manager.plugins["test-http-endpoint-native"]
|
|
manager.mu.RUnlock()
|
|
|
|
Expect(p).ToNot(BeNil())
|
|
Expect(p.manifest.Name).To(Equal("Test HTTP Endpoint Native Plugin"))
|
|
Expect(p.manifest.Permissions.Endpoints).ToNot(BeNil())
|
|
Expect(string(p.manifest.Permissions.Endpoints.Auth)).To(Equal("native"))
|
|
Expect(hasCapability(p.capabilities, CapabilityHTTPEndpoint)).To(BeTrue())
|
|
})
|
|
|
|
It("loads the public endpoint plugin", func() {
|
|
manager.mu.RLock()
|
|
p := manager.plugins["test-http-endpoint-public"]
|
|
manager.mu.RUnlock()
|
|
|
|
Expect(p).ToNot(BeNil())
|
|
Expect(p.manifest.Name).To(Equal("Test HTTP Endpoint Public Plugin"))
|
|
Expect(p.manifest.Permissions.Endpoints).ToNot(BeNil())
|
|
Expect(string(p.manifest.Permissions.Endpoints.Auth)).To(Equal("none"))
|
|
Expect(hasCapability(p.capabilities, CapabilityHTTPEndpoint)).To(BeTrue())
|
|
})
|
|
})
|
|
|
|
Describe("Subsonic Auth Endpoints", func() {
|
|
It("returns hello response with valid auth", func() {
|
|
req := httptest.NewRequest("GET", "/test-http-endpoint/hello?u=testuser", nil)
|
|
w := httptest.NewRecorder()
|
|
router.ServeHTTP(w, req)
|
|
|
|
Expect(w.Code).To(Equal(http.StatusOK))
|
|
Expect(w.Body.String()).To(Equal("Hello from plugin!"))
|
|
Expect(w.Header().Get("Content-Type")).To(Equal("text/plain"))
|
|
})
|
|
|
|
It("returns echo response with request details", func() {
|
|
req := httptest.NewRequest("POST", "/test-http-endpoint/echo?u=testuser&foo=bar", strings.NewReader("test body"))
|
|
w := httptest.NewRecorder()
|
|
router.ServeHTTP(w, req)
|
|
|
|
Expect(w.Code).To(Equal(http.StatusOK))
|
|
Expect(w.Header().Get("Content-Type")).To(Equal("application/json"))
|
|
|
|
var resp map[string]any
|
|
err := json.Unmarshal(w.Body.Bytes(), &resp)
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(resp["method"]).To(Equal("POST"))
|
|
Expect(resp["path"]).To(Equal("/echo"))
|
|
Expect(resp["body"]).To(Equal("test body"))
|
|
Expect(resp["hasUser"]).To(BeTrue())
|
|
Expect(resp["username"]).To(Equal("testuser"))
|
|
})
|
|
|
|
It("returns plugin-defined error status", func() {
|
|
req := httptest.NewRequest("GET", "/test-http-endpoint/error?u=testuser", nil)
|
|
w := httptest.NewRecorder()
|
|
router.ServeHTTP(w, req)
|
|
|
|
Expect(w.Code).To(Equal(http.StatusInternalServerError))
|
|
Expect(w.Body.String()).To(Equal("Something went wrong"))
|
|
})
|
|
|
|
It("returns plugin 404 for unknown paths", func() {
|
|
req := httptest.NewRequest("GET", "/test-http-endpoint/unknown?u=testuser", nil)
|
|
w := httptest.NewRecorder()
|
|
router.ServeHTTP(w, req)
|
|
|
|
Expect(w.Code).To(Equal(http.StatusNotFound))
|
|
Expect(w.Body.String()).To(Equal("Not found: /unknown"))
|
|
})
|
|
|
|
It("returns 401 without auth credentials", func() {
|
|
req := httptest.NewRequest("GET", "/test-http-endpoint/hello", nil)
|
|
w := httptest.NewRecorder()
|
|
router.ServeHTTP(w, req)
|
|
|
|
Expect(w.Code).To(Equal(http.StatusUnauthorized))
|
|
})
|
|
|
|
It("returns 401 with invalid auth credentials", func() {
|
|
req := httptest.NewRequest("GET", "/test-http-endpoint/hello?u=nonexistent", nil)
|
|
w := httptest.NewRecorder()
|
|
router.ServeHTTP(w, req)
|
|
|
|
Expect(w.Code).To(Equal(http.StatusUnauthorized))
|
|
})
|
|
})
|
|
|
|
Describe("Native Auth Endpoints", func() {
|
|
It("returns hello response with valid native auth", func() {
|
|
req := httptest.NewRequest("GET", "/test-http-endpoint-native/hello", nil)
|
|
req.Header.Set("X-Test-User", "testuser")
|
|
w := httptest.NewRecorder()
|
|
router.ServeHTTP(w, req)
|
|
|
|
Expect(w.Code).To(Equal(http.StatusOK))
|
|
Expect(w.Body.String()).To(Equal("Hello from native auth plugin!"))
|
|
Expect(w.Header().Get("Content-Type")).To(Equal("text/plain"))
|
|
})
|
|
|
|
It("returns echo response with user details", func() {
|
|
req := httptest.NewRequest("POST", "/test-http-endpoint-native/echo?foo=bar", strings.NewReader("native body"))
|
|
req.Header.Set("X-Test-User", "adminuser")
|
|
w := httptest.NewRecorder()
|
|
router.ServeHTTP(w, req)
|
|
|
|
Expect(w.Code).To(Equal(http.StatusOK))
|
|
Expect(w.Header().Get("Content-Type")).To(Equal("application/json"))
|
|
|
|
var resp map[string]any
|
|
err := json.Unmarshal(w.Body.Bytes(), &resp)
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(resp["method"]).To(Equal("POST"))
|
|
Expect(resp["path"]).To(Equal("/echo"))
|
|
Expect(resp["body"]).To(Equal("native body"))
|
|
Expect(resp["hasUser"]).To(BeTrue())
|
|
Expect(resp["username"]).To(Equal("adminuser"))
|
|
})
|
|
|
|
It("returns 401 without auth header", func() {
|
|
req := httptest.NewRequest("GET", "/test-http-endpoint-native/hello", nil)
|
|
w := httptest.NewRecorder()
|
|
router.ServeHTTP(w, req)
|
|
|
|
Expect(w.Code).To(Equal(http.StatusUnauthorized))
|
|
})
|
|
|
|
It("returns 401 with invalid auth header", func() {
|
|
req := httptest.NewRequest("GET", "/test-http-endpoint-native/hello", nil)
|
|
req.Header.Set("X-Test-User", "nonexistent")
|
|
w := httptest.NewRecorder()
|
|
router.ServeHTTP(w, req)
|
|
|
|
Expect(w.Code).To(Equal(http.StatusUnauthorized))
|
|
})
|
|
})
|
|
|
|
Describe("Public Endpoints (auth: none)", func() {
|
|
It("returns webhook response without auth", func() {
|
|
req := httptest.NewRequest("POST", "/test-http-endpoint-public/webhook", nil)
|
|
w := httptest.NewRecorder()
|
|
router.ServeHTTP(w, req)
|
|
|
|
Expect(w.Code).To(Equal(http.StatusOK))
|
|
Expect(w.Body.String()).To(Equal("webhook received"))
|
|
})
|
|
|
|
It("does not pass user info to public endpoints", func() {
|
|
req := httptest.NewRequest("GET", "/test-http-endpoint-public/check-no-user", nil)
|
|
w := httptest.NewRecorder()
|
|
router.ServeHTTP(w, req)
|
|
|
|
Expect(w.Code).To(Equal(http.StatusOK))
|
|
Expect(w.Body.String()).To(Equal("hasUser=false"))
|
|
})
|
|
})
|
|
|
|
Describe("Security Headers", func() {
|
|
It("includes security headers in authenticated endpoint responses", func() {
|
|
req := httptest.NewRequest("GET", "/test-http-endpoint/hello?u=testuser", nil)
|
|
w := httptest.NewRecorder()
|
|
router.ServeHTTP(w, req)
|
|
|
|
Expect(w.Code).To(Equal(http.StatusOK))
|
|
Expect(w.Header().Get("X-Content-Type-Options")).To(Equal("nosniff"))
|
|
Expect(w.Header().Get("Content-Security-Policy")).To(Equal("default-src 'none'; style-src 'unsafe-inline'; img-src data:; sandbox"))
|
|
})
|
|
|
|
It("includes security headers in public endpoint responses", func() {
|
|
req := httptest.NewRequest("POST", "/test-http-endpoint-public/webhook", nil)
|
|
w := httptest.NewRecorder()
|
|
router.ServeHTTP(w, req)
|
|
|
|
Expect(w.Code).To(Equal(http.StatusOK))
|
|
Expect(w.Header().Get("X-Content-Type-Options")).To(Equal("nosniff"))
|
|
Expect(w.Header().Get("Content-Security-Policy")).To(Equal("default-src 'none'; style-src 'unsafe-inline'; img-src data:; sandbox"))
|
|
})
|
|
|
|
It("overrides plugin-set security headers", func() {
|
|
req := httptest.NewRequest("POST", "/test-http-endpoint/echo?u=testuser", strings.NewReader("body"))
|
|
w := httptest.NewRecorder()
|
|
router.ServeHTTP(w, req)
|
|
|
|
Expect(w.Code).To(Equal(http.StatusOK))
|
|
Expect(w.Header().Get("X-Content-Type-Options")).To(Equal("nosniff"))
|
|
Expect(w.Header().Get("Content-Security-Policy")).To(Equal("default-src 'none'; style-src 'unsafe-inline'; img-src data:; sandbox"))
|
|
})
|
|
})
|
|
|
|
Describe("Unknown Plugin", func() {
|
|
It("returns 404 for nonexistent plugin", func() {
|
|
req := httptest.NewRequest("GET", "/nonexistent-plugin/hello", nil)
|
|
w := httptest.NewRecorder()
|
|
router.ServeHTTP(w, req)
|
|
|
|
Expect(w.Code).To(Equal(http.StatusNotFound))
|
|
})
|
|
})
|
|
|
|
Describe("User Authorization", func() {
|
|
var restrictedRouter http.Handler
|
|
|
|
BeforeAll(func() {
|
|
// Create a manager with a plugin restricted to specific users
|
|
restrictedTmpDir, err := os.MkdirTemp("", "http-endpoint-restricted-test-*")
|
|
Expect(err).ToNot(HaveOccurred())
|
|
|
|
srcPath := filepath.Join(testdataDir, "test-http-endpoint"+PackageExtension)
|
|
destPath := filepath.Join(restrictedTmpDir, "test-http-endpoint"+PackageExtension)
|
|
data, err := os.ReadFile(srcPath)
|
|
Expect(err).ToNot(HaveOccurred())
|
|
err = os.WriteFile(destPath, data, 0600)
|
|
Expect(err).ToNot(HaveOccurred())
|
|
|
|
hash := sha256.Sum256(data)
|
|
hashHex := hex.EncodeToString(hash[:])
|
|
|
|
DeferCleanup(configtest.SetupConfig())
|
|
conf.Server.Plugins.Enabled = true
|
|
conf.Server.Plugins.Folder = restrictedTmpDir
|
|
conf.Server.Plugins.AutoReload = false
|
|
conf.Server.CacheFolder = filepath.Join(restrictedTmpDir, "cache")
|
|
|
|
restrictedPluginRepo := tests.CreateMockPluginRepo()
|
|
restrictedPluginRepo.Permitted = true
|
|
restrictedPluginRepo.SetData(model.Plugins{{
|
|
ID: "test-http-endpoint",
|
|
Path: destPath,
|
|
SHA256: hashHex,
|
|
Enabled: true,
|
|
AllUsers: false,
|
|
Users: `["admin1"]`, // Only admin1 is allowed
|
|
}})
|
|
restrictedDS := &tests.MockDataStore{
|
|
MockedPlugin: restrictedPluginRepo,
|
|
MockedUser: userRepo,
|
|
}
|
|
|
|
restrictedManager := &Manager{
|
|
plugins: make(map[string]*plugin),
|
|
ds: restrictedDS,
|
|
metrics: noopMetricsRecorder{},
|
|
subsonicRouter: http.NotFoundHandler(),
|
|
}
|
|
err = restrictedManager.Start(GinkgoT().Context())
|
|
Expect(err).ToNot(HaveOccurred())
|
|
|
|
restrictedRouter = NewEndpointRouter(restrictedManager, restrictedDS, fakeSubsonicAuth, fakeNativeAuth)
|
|
|
|
DeferCleanup(func() {
|
|
_ = restrictedManager.Stop()
|
|
_ = os.RemoveAll(restrictedTmpDir)
|
|
})
|
|
})
|
|
|
|
It("allows authorized users", func() {
|
|
req := httptest.NewRequest("GET", "/test-http-endpoint/hello?u=adminuser", nil)
|
|
w := httptest.NewRecorder()
|
|
restrictedRouter.ServeHTTP(w, req)
|
|
|
|
Expect(w.Code).To(Equal(http.StatusOK))
|
|
Expect(w.Body.String()).To(Equal("Hello from plugin!"))
|
|
})
|
|
|
|
It("denies unauthorized users", func() {
|
|
req := httptest.NewRequest("GET", "/test-http-endpoint/hello?u=testuser", nil)
|
|
w := httptest.NewRecorder()
|
|
restrictedRouter.ServeHTTP(w, req)
|
|
|
|
Expect(w.Code).To(Equal(http.StatusForbidden))
|
|
})
|
|
})
|
|
|
|
Describe("Request without trailing path", func() {
|
|
It("handles requests to plugin root", func() {
|
|
req := httptest.NewRequest("GET", "/test-http-endpoint-public/webhook", nil)
|
|
w := httptest.NewRecorder()
|
|
router.ServeHTTP(w, req)
|
|
|
|
Expect(w.Code).To(Equal(http.StatusOK))
|
|
})
|
|
})
|
|
|
|
Describe("Binary Response", func() {
|
|
It("returns raw binary data intact", func() {
|
|
req := httptest.NewRequest("GET", "/test-http-endpoint/binary?u=testuser", nil)
|
|
w := httptest.NewRecorder()
|
|
router.ServeHTTP(w, req)
|
|
|
|
Expect(w.Code).To(Equal(http.StatusOK))
|
|
Expect(w.Header().Get("Content-Type")).To(Equal("image/png"))
|
|
// PNG header bytes
|
|
Expect(w.Body.Bytes()).To(Equal([]byte{0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A}))
|
|
})
|
|
})
|
|
|
|
Describe("Request body handling", func() {
|
|
It("passes request body to the plugin", func() {
|
|
body := `{"event":"push","ref":"refs/heads/main"}`
|
|
req := httptest.NewRequest("POST", "/test-http-endpoint/echo?u=testuser", strings.NewReader(body))
|
|
w := httptest.NewRecorder()
|
|
router.ServeHTTP(w, req)
|
|
|
|
Expect(w.Code).To(Equal(http.StatusOK))
|
|
|
|
respBody, err := io.ReadAll(w.Body)
|
|
Expect(err).ToNot(HaveOccurred())
|
|
|
|
var resp map[string]any
|
|
err = json.Unmarshal(respBody, &resp)
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(resp["body"]).To(Equal(body))
|
|
})
|
|
})
|
|
})
|