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"`
}
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
}
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())
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())
})
It("still follows a symlink planted in the mount by something else", func() {
if err := os.Symlink(outsideDir, filepath.Join(libraryDir, "planted")); err != nil {
Skip("cannot create symlinks here: " + err.Error())
}
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())
})
})