fix(plugins): confine plugin filesystem mounts to their root (#5881)

* 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.
This commit is contained in:
Deluan Quintão 2026-08-02 12:27:08 -04:00 committed by GitHub
parent d23b68a438
commit 810b14ed57
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 572 additions and 143 deletions

View File

@ -307,22 +307,10 @@ func (m *Manager) loadPluginWithConfig(p *model.Plugin) error {
return fmt.Errorf("opening package: %w", err)
}
// Build extism manifest
pluginManifest := extism.Manifest{
Wasm: []extism.Wasm{
extism.WasmData{Data: pkg.WasmBytes, Name: "main"},
},
Config: pluginConfig,
Timeout: uint64(defaultTimeout.Milliseconds()),
}
pluginManifest := buildExtismManifest(pkg, pluginConfig)
if pkg.Manifest.Permissions != nil && pkg.Manifest.Permissions.Http != nil {
if hosts := pkg.Manifest.Permissions.Http.RequiredHosts; len(hosts) > 0 {
pluginManifest.AllowedHosts = hosts
}
}
// Configure filesystem access for library permission
// Configure filesystem access for library permission, applied per instance
var fsConfig wazero.FSConfig
if pkg.Manifest.HasLibraryFilesystemPermission() {
adminCtx := adminContext(ctx)
libraries, err := m.ds.Library(adminCtx).GetAll()
@ -330,8 +318,7 @@ func (m *Manager) loadPluginWithConfig(p *model.Plugin) error {
return fmt.Errorf("failed to get libraries for filesystem access: %w", err)
}
allowedPaths := buildAllowedPaths(ctx, libraries, allowedLibraries, p.AllLibraries, p.AllowWriteAccess)
pluginManifest.AllowedPaths = allowedPaths
fsConfig = buildFSConfig(buildMounts(ctx, libraries, allowedLibraries, p.AllLibraries, p.AllowWriteAccess))
}
// Build host functions based on permissions from manifest
@ -386,7 +373,7 @@ func (m *Manager) loadPluginWithConfig(p *model.Plugin) error {
}
// Create instance to detect capabilities
instance, err := compiled.Instance(ctx, extism.PluginInstanceConfig{})
instance, err := compiled.Instance(ctx, instanceConfig(fsConfig))
if err != nil {
compiled.Close(ctx)
return fmt.Errorf("creating instance: %w", err)
@ -413,6 +400,7 @@ func (m *Manager) loadPluginWithConfig(p *model.Plugin) error {
allowedUserIDs: allowedUsers,
allUsers: p.AllUsers,
libraries: newLibraryAccess(allowedLibraries, p.AllLibraries),
fsConfig: fsConfig,
lyricsSem: make(chan struct{}, maxConcurrentLyricsCalls),
}
m.mu.Unlock()
@ -459,31 +447,18 @@ func parsePluginConfig(configJSON string) (map[string]string, error) {
return pluginConfig, nil
}
// buildAllowedPaths constructs the extism AllowedPaths map for filesystem access.
// When allowWriteAccess is false (default), paths are prefixed with "ro:" for read-only.
// Only libraries that match the allowed set (or all libraries if allLibraries is true) are included.
func buildAllowedPaths(ctx context.Context, libraries model.Libraries, allowedLibraryIDs []int, allLibraries, allowWriteAccess bool) map[string]string {
allowedLibrarySet := make(map[int]struct{}, len(allowedLibraryIDs))
for _, id := range allowedLibraryIDs {
allowedLibrarySet[id] = struct{}{}
// buildExtismManifest describes the plugin to extism. It must never set
// AllowedPaths: extism would replace our jailed FSConfig with plain dir mounts.
func buildExtismManifest(pkg *ndpPackage, pluginConfig map[string]string) extism.Manifest {
manifest := extism.Manifest{
Wasm: []extism.Wasm{extism.WasmData{Data: pkg.WasmBytes, Name: "main"}},
Config: pluginConfig,
Timeout: uint64(defaultTimeout.Milliseconds()),
}
allowedPaths := make(map[string]string)
for _, lib := range libraries {
_, allowed := allowedLibrarySet[lib.ID]
if allLibraries || allowed {
mountPoint := toPluginMountPoint(int32(lib.ID))
hostPath := lib.Path
if !allowWriteAccess {
hostPath = "ro:" + hostPath
}
allowedPaths[hostPath] = mountPoint
log.Trace(ctx, "Added library to allowed paths", "libraryID", lib.ID, "mountPoint", mountPoint, "writeAccess", allowWriteAccess, "hostPath", hostPath)
if pkg.Manifest.Permissions != nil && pkg.Manifest.Permissions.Http != nil {
if hosts := pkg.Manifest.Permissions.Http.RequiredHosts; len(hosts) > 0 {
manifest.AllowedHosts = hosts
}
}
if allowWriteAccess {
log.Info(ctx, "Granting read-write filesystem access to libraries", "libraryCount", len(allowedPaths), "allLibraries", allLibraries)
} else {
log.Debug(ctx, "Granting read-only filesystem access to libraries", "libraryCount", len(allowedPaths), "allLibraries", allLibraries)
}
return allowedPaths
return manifest
}

View File

@ -1,11 +1,32 @@
package plugins
import (
"github.com/navidrome/navidrome/model"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("buildExtismManifest", func() {
var pkg *ndpPackage
BeforeEach(func() {
pkg = &ndpPackage{
WasmBytes: []byte("wasm"),
Manifest: &Manifest{Permissions: &Permissions{
Library: &LibraryPermission{Reason: new("test"), Filesystem: true},
Http: &HTTPPermission{Reason: new("test"), RequiredHosts: []string{"example.com"}},
}},
}
})
It("never sets AllowedPaths, even with filesystem permission", func() {
Expect(buildExtismManifest(pkg, nil).AllowedPaths).To(BeEmpty())
})
It("carries the hosts the plugin is allowed to reach", func() {
Expect(buildExtismManifest(pkg, nil).AllowedHosts).To(Equal([]string{"example.com"}))
})
})
var _ = Describe("parsePluginConfig", func() {
It("returns nil for empty string", func() {
result, err := parsePluginConfig("")
@ -57,66 +78,3 @@ var _ = Describe("parsePluginConfig", func() {
Expect(result).ToNot(BeNil())
})
})
var _ = Describe("buildAllowedPaths", func() {
var libraries model.Libraries
BeforeEach(func() {
libraries = model.Libraries{
{ID: 1, Path: "/music/library1"},
{ID: 2, Path: "/music/library2"},
{ID: 3, Path: "/music/library3"},
}
})
Context("read-only (default)", func() {
It("mounts all libraries with ro: prefix when allLibraries is true", func() {
result := buildAllowedPaths(nil, libraries, nil, true, false)
Expect(result).To(HaveLen(3))
Expect(result).To(HaveKeyWithValue("ro:/music/library1", "/libraries/1"))
Expect(result).To(HaveKeyWithValue("ro:/music/library2", "/libraries/2"))
Expect(result).To(HaveKeyWithValue("ro:/music/library3", "/libraries/3"))
})
It("mounts only selected libraries with ro: prefix", func() {
result := buildAllowedPaths(nil, libraries, []int{1, 3}, false, false)
Expect(result).To(HaveLen(2))
Expect(result).To(HaveKeyWithValue("ro:/music/library1", "/libraries/1"))
Expect(result).To(HaveKeyWithValue("ro:/music/library3", "/libraries/3"))
Expect(result).ToNot(HaveKey("ro:/music/library2"))
})
})
Context("read-write (allowWriteAccess=true)", func() {
It("mounts all libraries without ro: prefix when allLibraries is true", func() {
result := buildAllowedPaths(nil, libraries, nil, true, true)
Expect(result).To(HaveLen(3))
Expect(result).To(HaveKeyWithValue("/music/library1", "/libraries/1"))
Expect(result).To(HaveKeyWithValue("/music/library2", "/libraries/2"))
Expect(result).To(HaveKeyWithValue("/music/library3", "/libraries/3"))
})
It("mounts only selected libraries without ro: prefix", func() {
result := buildAllowedPaths(nil, libraries, []int{2}, false, true)
Expect(result).To(HaveLen(1))
Expect(result).To(HaveKeyWithValue("/music/library2", "/libraries/2"))
})
})
Context("edge cases", func() {
It("returns empty map when no libraries match", func() {
result := buildAllowedPaths(nil, libraries, []int{99}, false, false)
Expect(result).To(BeEmpty())
})
It("returns empty map when libraries list is empty", func() {
result := buildAllowedPaths(nil, nil, []int{1}, false, false)
Expect(result).To(BeEmpty())
})
It("returns empty map when allLibraries is false and no IDs provided", func() {
result := buildAllowedPaths(nil, libraries, nil, false, false)
Expect(result).To(BeEmpty())
})
})
})

View File

@ -24,16 +24,24 @@ type plugin struct {
allowedUserIDs []string // User IDs this plugin can access (from DB configuration)
allUsers bool // If true, plugin can access all users
libraries libraryAccess
lyricsSem chan struct{} // Caps concurrent lyrics calls (see LyricsPlugin.GetLyrics)
lyricsSem chan struct{} // Caps concurrent lyrics calls (see LyricsPlugin.GetLyrics)
fsConfig wazero.FSConfig // Sandboxed library mounts, nil if no filesystem permission
}
// instanceConfig is used by every call site, so all instances get the sandboxed mounts.
func instanceConfig(fsConfig wazero.FSConfig) extism.PluginInstanceConfig {
moduleConfig := wazero.NewModuleConfig().WithSysWalltime().WithRandSource(rand.Reader)
if fsConfig != nil {
moduleConfig = moduleConfig.WithFSConfig(fsConfig)
}
return extism.PluginInstanceConfig{ModuleConfig: moduleConfig}
}
// instance creates a new plugin instance for the given context.
// The context is used for cancellation - if cancelled during a call,
// the module will be terminated and the instance becomes unusable.
func (p *plugin) instance(ctx context.Context) (*extism.Plugin, error) {
instance, err := p.compiled.Instance(ctx, extism.PluginInstanceConfig{
ModuleConfig: wazero.NewModuleConfig().WithSysWalltime().WithRandSource(rand.Reader),
})
instance, err := p.compiled.Instance(ctx, instanceConfig(p.fsConfig))
if err != nil {
return nil, err
}

View File

@ -12,6 +12,7 @@ import (
"os/exec"
"path/filepath"
"runtime"
"strings"
"testing"
"time"
@ -81,46 +82,43 @@ func createTestManagerWithPlugins(pluginConfig map[string]map[string]string, plu
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.
// 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())
// Copy test plugins to temp dir and build plugin list with SHA256
var enabledPlugins model.Plugins
for _, plugin := range plugins {
srcPath := filepath.Join(testdataDir, plugin)
destPath := filepath.Join(tmpDir, plugin)
data, err := os.ReadFile(srcPath)
Expect(err).ToNot(HaveOccurred())
err = os.WriteFile(destPath, data, 0600)
Expect(err).ToNot(HaveOccurred())
// Compute SHA256 for the plugin
hash := sha256.Sum256(data)
hashHex := hex.EncodeToString(hash[:])
pluginName := plugin[:len(plugin)-len(PackageExtension)] // Remove .ndp extension
// Build config JSON if provided
configJSON := ""
if pluginConfig != nil && pluginConfig[pluginName] != nil {
// Encode config to JSON
configBytes, err := json.Marshal(pluginConfig[pluginName])
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())
configJSON = string(configBytes)
enabledPlugins[i].Config = string(configBytes)
}
enabledPlugins = append(enabledPlugins, model.Plugin{
ID: pluginName,
Path: destPath,
SHA256: hashHex,
Enabled: true,
Config: configJSON,
AllUsers: true, // Allow all users by default in tests
})
}
// Setup config

157
plugins/sandbox_fs.go Normal file
View File

@ -0,0 +1,157 @@
package plugins
import (
"context"
"io/fs"
"path/filepath"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/tetratelabs/wazero"
experimentalsys "github.com/tetratelabs/wazero/experimental/sys"
"github.com/tetratelabs/wazero/experimental/sysfs"
"github.com/tetratelabs/wazero/sys"
)
// jailedFS confines a mount to its root: wazero resolves guest paths by bare
// concatenation. Named field, not embedding, so a new sys.FS method can't slip through.
type jailedFS struct {
fs experimentalsys.FS
}
// escapes reports whether a guest path can resolve outside the mount root.
// WASI only validates "/"-separated paths, missing `..\`, `C:x` and device names.
func escapes(path string) bool {
switch path {
case "", ".", "/": // how wazero opens the mount root itself
return false
}
return !filepath.IsLocal(path)
}
func (jailedFS) Symlink(string, string) experimentalsys.Errno {
return experimentalsys.EPERM
}
func (j jailedFS) OpenFile(path string, flag experimentalsys.Oflag, perm fs.FileMode) (experimentalsys.File, experimentalsys.Errno) {
if escapes(path) {
return nil, experimentalsys.EPERM
}
return j.fs.OpenFile(path, flag, perm)
}
func (j jailedFS) Lstat(path string) (sys.Stat_t, experimentalsys.Errno) {
if escapes(path) {
return sys.Stat_t{}, experimentalsys.EPERM
}
return j.fs.Lstat(path)
}
func (j jailedFS) Stat(path string) (sys.Stat_t, experimentalsys.Errno) {
if escapes(path) {
return sys.Stat_t{}, experimentalsys.EPERM
}
return j.fs.Stat(path)
}
func (j jailedFS) Mkdir(path string, perm fs.FileMode) experimentalsys.Errno {
if escapes(path) {
return experimentalsys.EPERM
}
return j.fs.Mkdir(path, perm)
}
func (j jailedFS) Chmod(path string, perm fs.FileMode) experimentalsys.Errno {
if escapes(path) {
return experimentalsys.EPERM
}
return j.fs.Chmod(path, perm)
}
func (j jailedFS) Rename(from, to string) experimentalsys.Errno {
if escapes(from) || escapes(to) {
return experimentalsys.EPERM
}
return j.fs.Rename(from, to)
}
func (j jailedFS) Rmdir(path string) experimentalsys.Errno {
if escapes(path) {
return experimentalsys.EPERM
}
return j.fs.Rmdir(path)
}
func (j jailedFS) Unlink(path string) experimentalsys.Errno {
if escapes(path) {
return experimentalsys.EPERM
}
return j.fs.Unlink(path)
}
func (j jailedFS) Link(oldPath, newPath string) experimentalsys.Errno {
if escapes(oldPath) || escapes(newPath) {
return experimentalsys.EPERM
}
return j.fs.Link(oldPath, newPath)
}
func (j jailedFS) Readlink(path string) (string, experimentalsys.Errno) {
if escapes(path) {
return "", experimentalsys.EPERM
}
return j.fs.Readlink(path)
}
func (j jailedFS) Utimens(path string, atim, mtim int64) experimentalsys.Errno {
if escapes(path) {
return experimentalsys.EPERM
}
return j.fs.Utimens(path, atim, mtim)
}
// mount is a host directory exposed to a plugin at guestPath.
type mount struct {
hostPath string
guestPath string
readOnly bool
}
// buildMounts lists the libraries the plugin may reach through the filesystem.
func buildMounts(ctx context.Context, libraries model.Libraries, allowedLibraryIDs []int, allLibraries, allowWriteAccess bool) []mount {
allowedLibrarySet := make(map[int]struct{}, len(allowedLibraryIDs))
for _, id := range allowedLibraryIDs {
allowedLibrarySet[id] = struct{}{}
}
var mounts []mount
for _, lib := range libraries {
_, allowed := allowedLibrarySet[lib.ID]
if allLibraries || allowed {
m := mount{hostPath: lib.Path, guestPath: toPluginMountPoint(int32(lib.ID)), readOnly: !allowWriteAccess}
mounts = append(mounts, m)
log.Trace(ctx, "Added library to plugin mounts", "libraryID", lib.ID, "mountPoint", m.guestPath, "readOnly", m.readOnly, "hostPath", m.hostPath)
}
}
if allowWriteAccess {
log.Info(ctx, "Granting read-write filesystem access to libraries", "libraryCount", len(mounts), "allLibraries", allLibraries)
} else {
log.Debug(ctx, "Granting read-only filesystem access to libraries", "libraryCount", len(mounts), "allLibraries", allLibraries)
}
return mounts
}
// buildFSConfig mounts each host directory jailed to its root.
func buildFSConfig(mounts []mount) wazero.FSConfig {
if len(mounts) == 0 {
return nil
}
cfg := wazero.NewFSConfig()
for _, m := range mounts {
var mounted experimentalsys.FS = jailedFS{fs: sysfs.DirFS(m.hostPath)}
if m.readOnly {
mounted = &sysfs.ReadFS{FS: mounted}
}
cfg = cfg.(sysfs.FSConfig).WithSysFSMount(mounted, m.guestPath)
}
return cfg
}

View File

@ -0,0 +1,127 @@
package plugins
import (
"os"
"path/filepath"
"runtime"
"github.com/navidrome/navidrome/model"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
experimentalsys "github.com/tetratelabs/wazero/experimental/sys"
"github.com/tetratelabs/wazero/experimental/sysfs"
)
var _ = Describe("buildMounts", func() {
var libraries model.Libraries
BeforeEach(func() {
libraries = model.Libraries{
{ID: 1, Path: "/music/library1"},
{ID: 2, Path: "/music/library2"},
{ID: 3, Path: "/music/library3"},
}
})
It("mounts all libraries read-only by default", func() {
Expect(buildMounts(nil, libraries, nil, true, false)).To(Equal([]mount{
{hostPath: "/music/library1", guestPath: "/libraries/1", readOnly: true},
{hostPath: "/music/library2", guestPath: "/libraries/2", readOnly: true},
{hostPath: "/music/library3", guestPath: "/libraries/3", readOnly: true},
}))
})
It("mounts only the selected libraries", func() {
Expect(buildMounts(nil, libraries, []int{1, 3}, false, false)).To(Equal([]mount{
{hostPath: "/music/library1", guestPath: "/libraries/1", readOnly: true},
{hostPath: "/music/library3", guestPath: "/libraries/3", readOnly: true},
}))
})
It("mounts writable when write access is granted", func() {
Expect(buildMounts(nil, libraries, []int{2}, false, true)).To(Equal([]mount{
{hostPath: "/music/library2", guestPath: "/libraries/2", readOnly: false},
}))
})
DescribeTable("mounts nothing",
func(libs model.Libraries, allowedIDs []int, allLibraries bool) {
Expect(buildMounts(nil, libs, allowedIDs, allLibraries, false)).To(BeEmpty())
},
Entry("when no library matches", libraries, []int{99}, false),
Entry("when there are no libraries", model.Libraries(nil), []int{1}, false),
Entry("when no library is selected", libraries, nil, false),
)
})
var _ = Describe("jailedFS", func() {
var (
fs jailedFS
root string
outsideDir string
)
BeforeEach(func() {
tmpDir := GinkgoT().TempDir()
root = filepath.Join(tmpDir, "root")
outsideDir = filepath.Join(tmpDir, "outside")
Expect(os.MkdirAll(root, 0755)).To(Succeed())
Expect(os.MkdirAll(outsideDir, 0755)).To(Succeed())
Expect(os.WriteFile(filepath.Join(outsideDir, "secret.txt"), []byte("secret"), 0600)).To(Succeed())
fs = jailedFS{sysfs.DirFS(root)}
})
It("denies creating symlinks", func() {
Expect(fs.Symlink("../outside", "link")).To(Equal(experimentalsys.EPERM))
})
It("denies reading through a path that escapes the root", func() {
_, errno := fs.OpenFile("../outside/secret.txt", experimentalsys.O_RDONLY, 0)
Expect(errno).To(Equal(experimentalsys.EPERM))
})
It("denies traversal with the host separator", func() {
_, errno := fs.OpenFile(`..\outside\secret.txt`, experimentalsys.O_RDONLY, 0)
if runtime.GOOS == "windows" {
Expect(errno).To(Equal(experimentalsys.EPERM))
} else {
// A backslash is a legal filename character on POSIX, not a separator
Expect(errno).To(Equal(experimentalsys.ENOENT))
}
})
It("denies absolute paths", func() {
_, errno := fs.OpenFile("/etc/passwd", experimentalsys.O_RDONLY, 0)
Expect(errno).To(Equal(experimentalsys.EPERM))
})
It("denies creating a directory outside the root", func() {
Expect(fs.Mkdir("../outside/new-dir", 0755)).To(Equal(experimentalsys.EPERM))
Expect(filepath.Join(outsideDir, "new-dir")).ToNot(BeADirectory())
})
It("denies escaping through either side of a rename", func() {
Expect(fs.Rename("../outside/secret.txt", "stolen.txt")).To(Equal(experimentalsys.EPERM))
Expect(fs.Rename("inside.txt", "../outside/leaked.txt")).To(Equal(experimentalsys.EPERM))
})
// Music libraries symlink in folders from elsewhere; following them is intended
It("follows symlinks that already exist in the mount", func() {
if err := os.Symlink(outsideDir, filepath.Join(root, "linked")); err != nil {
Skip("cannot create symlinks here: " + err.Error()) // Windows without privileges
}
_, errno := fs.OpenFile("linked/secret.txt", experimentalsys.O_RDONLY, 0)
Expect(errno).To(BeZero())
})
It("allows access within the root", func() {
Expect(fs.Mkdir("sub", 0755)).To(BeZero())
f, errno := fs.OpenFile("sub/file.txt", experimentalsys.O_CREAT|experimentalsys.O_WRONLY, 0600)
Expect(errno).To(BeZero())
Expect(f.Close()).To(BeZero())
Expect(filepath.Join(root, "sub", "file.txt")).To(BeAnExistingFile())
})
})

182
plugins/sandbox_fs_test.go Normal file
View File

@ -0,0 +1,182 @@
//go:build !windows
package plugins
import (
"context"
"encoding/json"
"net/http"
"os"
"path/filepath"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/conf/configtest"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/tests"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
type sandboxInput struct {
Operation string `json:"operation"`
MountPoint string `json:"mount_point,omitempty"`
FilePath string `json:"file_path,omitempty"`
Content string `json:"content,omitempty"`
Target string `json:"target,omitempty"`
}
type sandboxOutput struct {
FileContent string `json:"file_content,omitempty"`
Error *string `json:"error,omitempty"`
}
// startSandboxManager loads test-library against libraryDir with the given grant.
func startSandboxManager(tmpDir, libraryDir string, grant func(*model.Plugin)) *Manager {
GinkgoHelper()
installed := installTestPlugins(tmpDir, "test-library"+PackageExtension)
grant(&installed[0])
DeferCleanup(configtest.SetupConfig())
conf.Server.Plugins.Enabled = true
conf.Server.Plugins.Folder = conf.NewDir(tmpDir)
conf.Server.Plugins.AutoReload = false
mockPluginRepo := tests.CreateMockPluginRepo()
mockPluginRepo.Permitted = true
mockPluginRepo.SetData(installed)
mockLibraryRepo := &tests.MockLibraryRepo{}
mockLibraryRepo.SetData(model.Libraries{{ID: 1, Name: "Test Library", Path: libraryDir}})
manager := &Manager{
plugins: make(map[string]*plugin),
ds: &tests.MockDataStore{MockedPlugin: mockPluginRepo, MockedLibrary: mockLibraryRepo},
subsonicRouter: http.NotFoundHandler(),
}
Expect(manager.Start(GinkgoT().Context())).To(Succeed())
DeferCleanup(func() { _ = manager.Stop() })
return manager
}
// callSandbox runs one filesystem operation inside the plugin sandbox.
func callSandbox(manager *Manager, input sandboxInput) sandboxOutput {
GinkgoHelper()
manager.mu.RLock()
p := manager.plugins["test-library"]
manager.mu.RUnlock()
Expect(p).ToNot(BeNil())
instance, err := p.instance(context.Background())
Expect(err).ToNot(HaveOccurred())
defer instance.Close(context.Background())
inputBytes, err := json.Marshal(input)
Expect(err).ToNot(HaveOccurred())
_, outputBytes, err := instance.Call("nd_test_library", inputBytes)
Expect(err).ToNot(HaveOccurred())
var output sandboxOutput
Expect(json.Unmarshal(outputBytes, &output)).To(Succeed())
return output
}
var _ = Describe("Plugin filesystem sandbox", Ordered, ContinueOnFailure, func() {
var (
manager *Manager
libraryDir string
outsideDir string
secretFile string
mountPoint string
)
call := func(input sandboxInput) sandboxOutput {
GinkgoHelper()
input.MountPoint = mountPoint
return callSandbox(manager, input)
}
BeforeAll(func() {
tmpDir := GinkgoT().TempDir()
libraryDir = filepath.Join(tmpDir, "music-library")
Expect(os.MkdirAll(libraryDir, 0755)).To(Succeed())
// Sibling of the mount root: anything reached here escaped the sandbox
outsideDir = filepath.Join(tmpDir, "outside")
Expect(os.MkdirAll(outsideDir, 0755)).To(Succeed())
secretFile = filepath.Join(outsideDir, "secret.txt")
Expect(os.WriteFile(secretFile, []byte("secret"), 0600)).To(Succeed())
mountPoint = toPluginMountPoint(1)
manager = startSandboxManager(tmpDir, libraryDir, func(p *model.Plugin) {
p.AllLibraries = true
p.AllowWriteAccess = true
})
})
It("allows writing inside the mount", func() {
out := call(sandboxInput{Operation: "write_file", FilePath: "inside.txt", Content: "hello"})
Expect(out.Error).To(BeNil())
Expect(os.ReadFile(filepath.Join(libraryDir, "inside.txt"))).To(BeEquivalentTo("hello"))
})
It("cannot escape the mount by creating a symlink", func() {
linked := call(sandboxInput{Operation: "symlink", FilePath: "escape", Target: "../outside"})
Expect(linked.Error).ToNot(BeNil())
_, err := os.Lstat(filepath.Join(libraryDir, "escape"))
Expect(err).To(MatchError(os.ErrNotExist), "the plugin created a symlink out of the mount")
wrote := call(sandboxInput{Operation: "write_file", FilePath: "escape/via-created-symlink.txt", Content: "escaped"})
Expect(wrote.Error).ToNot(BeNil())
Expect(filepath.Join(outsideDir, "via-created-symlink.txt")).ToNot(BeAnExistingFile())
})
// Accepted residual, pinned so a future tightening can't happen silently
It("still follows a symlink planted in the mount by something else", func() {
Expect(os.Symlink(outsideDir, filepath.Join(libraryDir, "planted"))).To(Succeed())
out := call(sandboxInput{Operation: "write_file", FilePath: "planted/via-symlink.txt", Content: "escaped"})
Expect(out.Error).To(BeNil())
Expect(filepath.Join(outsideDir, "via-symlink.txt")).To(BeAnExistingFile())
})
It("cannot read outside the mount with ..", func() {
out := call(sandboxInput{Operation: "read_file", FilePath: "../outside/secret.txt"})
Expect(out.Error).ToNot(BeNil())
Expect(out.FileContent).ToNot(Equal("secret"))
})
It("cannot write outside the mount with ..", func() {
out := call(sandboxInput{Operation: "write_file", FilePath: "../outside/via-dotdot.txt", Content: "escaped"})
Expect(out.Error).ToNot(BeNil())
Expect(filepath.Join(outsideDir, "via-dotdot.txt")).ToNot(BeAnExistingFile())
})
})
var _ = Describe("Plugin filesystem sandbox without library access", Ordered, func() {
var manager *Manager
var libraryDir string
BeforeAll(func() {
tmpDir := GinkgoT().TempDir()
libraryDir = filepath.Join(tmpDir, "music-library")
Expect(os.MkdirAll(libraryDir, 0755)).To(Succeed())
Expect(os.WriteFile(filepath.Join(libraryDir, "track.txt"), []byte("audio"), 0600)).To(Succeed())
manager = startSandboxManager(tmpDir, libraryDir, func(*model.Plugin) {})
})
It("mounts nothing when no library is granted", func() {
manager.mu.RLock()
p := manager.plugins["test-library"]
manager.mu.RUnlock()
Expect(p.fsConfig).To(BeNil())
out := callSandbox(manager, sandboxInput{Operation: "read_file", MountPoint: toPluginMountPoint(1), FilePath: "track.txt"})
Expect(out.Error).ToNot(BeNil())
})
})

View File

@ -14,10 +14,12 @@ import (
// TestLibraryInput is the input for nd_test_library callback.
type TestLibraryInput struct {
Operation string `json:"operation"` // "get_library", "get_all_libraries", "read_file", "list_dir"
Operation string `json:"operation"` // "get_library", "get_all_libraries", "read_file", "list_dir", "write_file", "symlink"
LibraryID int32 `json:"library_id,omitempty"`
MountPoint string `json:"mount_point,omitempty"` // For filesystem operations
FilePath string `json:"file_path,omitempty"` // For read_file operation (relative to mount point)
Content string `json:"content,omitempty"` // For write_file operation
Target string `json:"target,omitempty"` // For symlink operation
}
// TestLibraryOutput is the output from nd_test_library callback.
@ -88,6 +90,28 @@ func ndTestLibrary() int32 {
pdk.OutputJSON(TestLibraryOutput{DirEntries: names})
return 0
case "write_file":
// Write a file to the mounted library directory
fullPath := filepath.Join(input.MountPoint, input.FilePath)
if err := os.WriteFile(fullPath, []byte(input.Content), 0600); err != nil {
errStr := err.Error()
pdk.OutputJSON(TestLibraryOutput{Error: &errStr})
return 0
}
pdk.OutputJSON(TestLibraryOutput{})
return 0
case "symlink":
// Create a symlink inside the mounted library directory
fullPath := filepath.Join(input.MountPoint, input.FilePath)
if err := os.Symlink(input.Target, fullPath); err != nil {
errStr := err.Error()
pdk.OutputJSON(TestLibraryOutput{Error: &errStr})
return 0
}
pdk.OutputJSON(TestLibraryOutput{})
return 0
default:
errStr := "unknown operation: " + input.Operation
pdk.OutputJSON(TestLibraryOutput{Error: &errStr})