🏡 index : quine-global/quintodrome.git

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() {
		if err := os.Symlink(outsideDir, filepath.Join(libraryDir, "planted")); err != nil {
			Skip("cannot create symlinks here: " + err.Error()) // Windows without privileges
		}

		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())
	})
})