mirror of
https://github.com/navidrome/navidrome.git
synced 2026-08-04 03:02:37 -04:00
* fix(plugins): confine plugin filesystem mounts to their root A plugin granted read-write filesystem access could escape its mount by creating a relative symlink inside it and then writing through that link, reaching any path the server process can write, including navidrome.db. wazero resolves guest paths by concatenating them onto the host root. Its WASI layer validates every path argument except the symlink target, which path_symlink forwards unvalidated by design, and fs.ValidPath splits on "/" only, so on Windows a "..\" path escapes the mount as well. Mounts now go through a jailedFS wrapper that denies symlink creation and rejects any path that is not filepath.IsLocal. That requires bypassing extism's AllowedPaths, which discards any FSConfig passed alongside it, so the mounts are built directly and applied per instance instead. Following symlinks that already exist in a mount is unchanged: music libraries rely on it, and read-only mounts already reject creating new ones. * test(plugins): guard against setting extism AllowedPaths Extracts the extism manifest construction so a test can assert AllowedPaths is never set. Setting it makes extism build its own FSConfig and discard the jailed mounts, silently restoring the symlink escape. Verified by simulating the regression: with AllowedPaths populated for plugins holding the filesystem permission, the new spec fails, as do two of the end-to-end sandbox specs.
177 lines
5.8 KiB
Go
177 lines
5.8 KiB
Go
//go:build !windows
|
|
|
|
package plugins
|
|
|
|
import (
|
|
"context"
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"net/http"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"runtime"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/navidrome/navidrome/conf"
|
|
"github.com/navidrome/navidrome/conf/configtest"
|
|
"github.com/navidrome/navidrome/log"
|
|
"github.com/navidrome/navidrome/model"
|
|
"github.com/navidrome/navidrome/tests"
|
|
. "github.com/onsi/ginkgo/v2"
|
|
. "github.com/onsi/gomega"
|
|
)
|
|
|
|
const testDataDir = "plugins/testdata"
|
|
|
|
// Shared test state initialized in BeforeSuite
|
|
var (
|
|
testdataDir string // Path to testdata folder with test plugin .ndp packages
|
|
tmpPluginsDir string // Temp directory for plugin tests that modify files
|
|
testManager *Manager
|
|
)
|
|
|
|
func TestPlugins(t *testing.T) {
|
|
tests.Init(t, false)
|
|
buildTestPlugins(t, testDataDir)
|
|
|
|
// Create a shared wazero compilation cache directory.
|
|
// All test managers will point CacheFolder here so that WASM compilation
|
|
// is done once per binary and then reused from disk cache.
|
|
sharedCacheDir, err := os.MkdirTemp("", "plugins-shared-cache-*")
|
|
if err != nil {
|
|
t.Fatalf("Failed to create shared cache dir: %v", err)
|
|
}
|
|
t.Cleanup(func() { os.RemoveAll(sharedCacheDir) })
|
|
|
|
// Set CacheFolder globally so all tests (including those using
|
|
// configtest.SetupConfig) inherit it without needing to set it manually.
|
|
conf.Server.CacheFolder = conf.NewDir(sharedCacheDir)
|
|
|
|
log.SetLevel(log.LevelFatal)
|
|
RegisterFailHandler(Fail)
|
|
RunSpecs(t, "Plugins Suite")
|
|
}
|
|
|
|
func buildTestPlugins(t *testing.T, path string) {
|
|
t.Helper()
|
|
start := time.Now()
|
|
t.Logf("[BeforeSuite] Current working directory: %s", path)
|
|
cmd := exec.Command("make", "-C", path)
|
|
out, err := cmd.CombinedOutput()
|
|
t.Logf("[BeforeSuite] Make output: %s elapsed: %s", string(out), time.Since(start))
|
|
if err != nil {
|
|
t.Fatalf("Failed to build test plugins: %v", err)
|
|
}
|
|
}
|
|
|
|
// createTestManager creates a new plugin Manager with the given plugin config.
|
|
// It creates a temp directory, copies the test-metadata-agent plugin, and starts the manager.
|
|
// Returns the manager, temp directory path, and a cleanup function.
|
|
func createTestManager(pluginConfig map[string]map[string]string) (*Manager, string) {
|
|
return createTestManagerWithPlugins(pluginConfig, "test-metadata-agent"+PackageExtension)
|
|
}
|
|
|
|
// createTestManagerWithPlugins creates a new plugin Manager with the given plugin config
|
|
// and specified plugins. It creates a temp directory, copies the specified plugins, and starts the manager.
|
|
// Returns the manager and temp directory path.
|
|
func createTestManagerWithPlugins(pluginConfig map[string]map[string]string, plugins ...string) (*Manager, string) {
|
|
return createTestManagerWithPluginsAndMetrics(pluginConfig, noopMetricsRecorder{}, plugins...)
|
|
}
|
|
|
|
// installTestPlugins copies the given .ndp packages into dir and returns their
|
|
// enabled DB rows, so callers can grant whatever access the test needs.
|
|
func installTestPlugins(dir string, plugins ...string) model.Plugins {
|
|
var rows model.Plugins
|
|
for _, plugin := range plugins {
|
|
data, err := os.ReadFile(filepath.Join(testdataDir, plugin))
|
|
Expect(err).ToNot(HaveOccurred())
|
|
destPath := filepath.Join(dir, plugin)
|
|
Expect(os.WriteFile(destPath, data, 0600)).To(Succeed())
|
|
|
|
hash := sha256.Sum256(data)
|
|
rows = append(rows, model.Plugin{
|
|
ID: strings.TrimSuffix(plugin, PackageExtension),
|
|
Path: destPath,
|
|
SHA256: hex.EncodeToString(hash[:]),
|
|
Enabled: true,
|
|
})
|
|
}
|
|
return rows
|
|
}
|
|
|
|
// createTestManagerWithPluginsAndMetrics creates a new plugin Manager with the given plugin config,
|
|
// metrics recorder, and specified plugins. It creates a temp directory, copies the specified plugins,
|
|
// and starts the manager. Returns the manager and temp directory path.
|
|
func createTestManagerWithPluginsAndMetrics(pluginConfig map[string]map[string]string, metrics PluginMetricsRecorder, plugins ...string) (*Manager, string) {
|
|
// Create temp directory
|
|
tmpDir, err := os.MkdirTemp("", "plugins-test-*")
|
|
Expect(err).ToNot(HaveOccurred())
|
|
|
|
enabledPlugins := installTestPlugins(tmpDir, plugins...)
|
|
for i, p := range enabledPlugins {
|
|
enabledPlugins[i].AllUsers = true // Allow all users by default in tests
|
|
if pluginConfig[p.ID] != nil {
|
|
configBytes, err := json.Marshal(pluginConfig[p.ID])
|
|
Expect(err).ToNot(HaveOccurred())
|
|
enabledPlugins[i].Config = string(configBytes)
|
|
}
|
|
}
|
|
|
|
// Setup config
|
|
DeferCleanup(configtest.SetupConfig())
|
|
conf.Server.Plugins.Enabled = true
|
|
conf.Server.Plugins.Folder = conf.NewDir(tmpDir)
|
|
conf.Server.Plugins.AutoReload = false
|
|
|
|
// Setup mock DataStore with pre-enabled plugins
|
|
mockPluginRepo := tests.CreateMockPluginRepo()
|
|
mockPluginRepo.Permitted = true
|
|
mockPluginRepo.SetData(enabledPlugins)
|
|
dataStore := &tests.MockDataStore{MockedPlugin: mockPluginRepo}
|
|
|
|
// Create and start manager
|
|
manager := &Manager{
|
|
plugins: make(map[string]*plugin),
|
|
ds: dataStore,
|
|
metrics: metrics,
|
|
subsonicRouter: http.NotFoundHandler(), // Stub router for tests
|
|
}
|
|
err = manager.Start(GinkgoT().Context())
|
|
Expect(err).ToNot(HaveOccurred())
|
|
|
|
DeferCleanup(func() {
|
|
_ = manager.Stop()
|
|
_ = os.RemoveAll(tmpDir)
|
|
})
|
|
|
|
return manager, tmpDir
|
|
}
|
|
|
|
var _ = BeforeSuite(func() {
|
|
// Get testdata directory (where test plugin .ndp packages live)
|
|
_, currentFile, _, ok := runtime.Caller(0)
|
|
Expect(ok).To(BeTrue())
|
|
testdataDir = filepath.Join(filepath.Dir(currentFile), "testdata")
|
|
|
|
// Create shared manager for most tests
|
|
testManager, tmpPluginsDir = createTestManager(nil)
|
|
})
|
|
|
|
var _ = AfterSuite(func() {
|
|
if testManager != nil {
|
|
_ = testManager.Stop()
|
|
}
|
|
if tmpPluginsDir != "" {
|
|
_ = os.RemoveAll(tmpPluginsDir)
|
|
}
|
|
})
|
|
|
|
// noopMetricsRecorder is a no-op implementation of PluginMetricsRecorder for tests
|
|
type noopMetricsRecorder struct{}
|
|
|
|
func (noopMetricsRecorder) RecordPluginRequest(context.Context, string, string, bool, int64) {}
|