navidrome/plugins/sandbox_fs_internal_test.go
Deluan Quintão 810b14ed57
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.
2026-08-02 12:27:08 -04:00

128 lines
4.3 KiB
Go

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