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.
144 lines
4.6 KiB
Go
144 lines
4.6 KiB
Go
package plugins
|
|
|
|
import (
|
|
"context"
|
|
"crypto/rand"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
|
|
extism "github.com/extism/go-sdk"
|
|
"github.com/navidrome/navidrome/model"
|
|
"github.com/tetratelabs/wazero"
|
|
)
|
|
|
|
// plugin represents a loaded plugin
|
|
type plugin struct {
|
|
name string // Plugin name (from filename)
|
|
path string // Path to the wasm file
|
|
manifest *Manifest
|
|
compiled *extism.CompiledPlugin
|
|
capabilities []Capability // Auto-detected capabilities based on exported functions
|
|
closers []io.Closer // Cleanup functions to call on unload
|
|
metrics PluginMetricsRecorder
|
|
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)
|
|
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, instanceConfig(p.fsConfig))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
instance.SetLogger(extismLogger(p.name))
|
|
return instance, nil
|
|
}
|
|
|
|
func (p *plugin) Close() error {
|
|
var errs []error
|
|
for _, f := range p.closers {
|
|
err := f.Close()
|
|
if err != nil {
|
|
errs = append(errs, err)
|
|
}
|
|
}
|
|
return errors.Join(errs...)
|
|
}
|
|
|
|
func (p *plugin) hasLibraryFilesystemAccess(libID int) bool {
|
|
return p.manifest.HasLibraryFilesystemPermission() && p.libraries.contains(libID)
|
|
}
|
|
|
|
// libraryAccess captures the set of libraries a plugin is permitted to see,
|
|
// precomputed at load time for O(1) lookup.
|
|
type libraryAccess struct {
|
|
allLibraries bool
|
|
libraryIDSet map[int]struct{}
|
|
}
|
|
|
|
func newLibraryAccess(allowedLibraryIDs []int, allLibraries bool) libraryAccess {
|
|
set := make(map[int]struct{}, len(allowedLibraryIDs))
|
|
for _, id := range allowedLibraryIDs {
|
|
set[id] = struct{}{}
|
|
}
|
|
return libraryAccess{allLibraries: allLibraries, libraryIDSet: set}
|
|
}
|
|
|
|
func (a libraryAccess) contains(libID int) bool {
|
|
if a.allLibraries {
|
|
return true
|
|
}
|
|
_, ok := a.libraryIDSet[libID]
|
|
return ok
|
|
}
|
|
|
|
// configured reports whether the plugin has any library scope (all, or specific).
|
|
func (a libraryAccess) configured() bool {
|
|
return a.allLibraries || len(a.libraryIDSet) > 0
|
|
}
|
|
|
|
// userAccess captures the set of users a plugin is permitted to act as,
|
|
// precomputed at load time for O(1) lookup.
|
|
type userAccess struct {
|
|
allUsers bool
|
|
userIDSet map[string]struct{}
|
|
}
|
|
|
|
func newUserAccess(allowedUserIDs []string, allUsers bool) userAccess {
|
|
set := make(map[string]struct{}, len(allowedUserIDs))
|
|
for _, id := range allowedUserIDs {
|
|
set[id] = struct{}{}
|
|
}
|
|
return userAccess{allUsers: allUsers, userIDSet: set}
|
|
}
|
|
|
|
// allows reports whether the plugin may act as the given user ID.
|
|
func (a userAccess) allows(userID string) bool {
|
|
if a.allUsers {
|
|
return true
|
|
}
|
|
_, ok := a.userIDSet[userID]
|
|
return ok
|
|
}
|
|
|
|
// resolve looks up a user by username and authorizes it against this access set,
|
|
// distinguishing an absent user from a backend failure.
|
|
//
|
|
// When the plugin has no user scope at all, it rejects before the lookup with a
|
|
// single fixed error, so a caller cannot tell a real account from a missing one by
|
|
// the error text (username enumeration).
|
|
func (a userAccess) resolve(ctx context.Context, ds model.DataStore, username string) (*model.User, error) {
|
|
if !a.allUsers && len(a.userIDSet) == 0 {
|
|
return nil, fmt.Errorf("plugin is not authorized to scope by user")
|
|
}
|
|
usr, err := ds.User(ctx).FindByUsername(username)
|
|
if err != nil {
|
|
if errors.Is(err, model.ErrNotFound) {
|
|
return nil, fmt.Errorf("user %q not found", username)
|
|
}
|
|
return nil, fmt.Errorf("looking up user %q: %w", username, err)
|
|
}
|
|
if usr == nil { // defensive: a conforming repo returns ErrNotFound, not (nil, nil)
|
|
return nil, fmt.Errorf("user %q not found", username)
|
|
}
|
|
if !a.allows(usr.ID) {
|
|
return nil, fmt.Errorf("plugin is not allowed to act as user %q", username)
|
|
}
|
|
return usr, nil
|
|
}
|