navidrome/plugins/package.go
Deluan Quintão 77726af59c
fix(plugins): reject plugin IDs that are unusable as directory names (#5886)
The plugin ID is derived from the package filename and used verbatim as a
directory name under DataFolder/plugins by the kvstore and taskqueue host
services. A package installed as '..ndp' yields the ID '.', whose data
directory resolves to the parent of every other plugin's directory, so it
overlaps their private data. On Windows, trailing dots and spaces are dropped
during path normalization, so 'foo..ndp' and 'foo.ndp' yield distinct IDs that
resolve to the same directory and would share the same SQLite files.

Discovery and the file watcher now derive the ID through pluginIDFromPath,
which rejects '.', '..', empty names, separators, trailing dots or spaces, and
anything filepath.IsLocal refuses (Windows reserved names, drive-relative
paths). The loader repeats the check, since a sync failure is non-fatal and
could otherwise leave a stale row reaching the host services.
2026-08-03 13:09:53 -04:00

135 lines
3.4 KiB
Go

package plugins
import (
"archive/zip"
"errors"
"fmt"
"io"
"path/filepath"
"strings"
)
const (
// PackageExtension is the file extension for Navidrome plugin packages.
PackageExtension = ".ndp"
// manifestFileName is the name of the manifest file inside the package.
manifestFileName = "manifest.json"
// wasmFileName is the name of the WebAssembly module inside the package.
wasmFileName = "plugin.wasm"
)
// validPluginID reports whether id is usable. It names a directory under
// DataFolder/plugins, so a path-like one would point at another plugin's data.
func validPluginID(id string) bool {
if id == "." || id == ".." || strings.ContainsAny(id, `/\`) || !filepath.IsLocal(id) {
return false
}
// Windows drops trailing dots and spaces, so "foo." and "foo" would end up
// sharing a directory
return strings.TrimRight(id, ". ") == id
}
// pluginIDFromPath derives the plugin ID from a package path.
func pluginIDFromPath(path string) (string, bool) {
id := strings.TrimSuffix(filepath.Base(path), PackageExtension)
if !validPluginID(id) {
return "", false
}
return id, true
}
// ndpPackage represents a loaded .ndp plugin package.
// It contains the manifest and wasm bytes read from the archive.
type ndpPackage struct {
Manifest *Manifest
WasmBytes []byte
}
// openPackage opens an .ndp file and extracts the manifest and wasm bytes.
// The caller does not need to call Close() - all resources are read into memory.
func openPackage(ndpPath string) (*ndpPackage, error) {
// Open the zip archive
zr, err := zip.OpenReader(ndpPath)
if err != nil {
return nil, fmt.Errorf("opening package: %w", err)
}
defer zr.Close()
var manifestBytes []byte
var wasmBytes []byte
for _, f := range zr.File {
switch f.Name {
case manifestFileName:
manifestBytes, err = readZipFile(f)
if err != nil {
return nil, fmt.Errorf("reading manifest: %w", err)
}
case wasmFileName:
wasmBytes, err = readZipFile(f)
if err != nil {
return nil, fmt.Errorf("reading wasm: %w", err)
}
}
}
if manifestBytes == nil {
return nil, errors.New("package missing manifest.json")
}
if wasmBytes == nil {
return nil, errors.New("package missing plugin.wasm")
}
// Parse and validate manifest
manifest, err := ParseManifest(manifestBytes)
if err != nil {
return nil, fmt.Errorf("parsing manifest: %w", err)
}
return &ndpPackage{
Manifest: manifest,
WasmBytes: wasmBytes,
}, nil
}
// ReadManifest reads and validates the manifest from a .ndp file without loading
// the wasm bytes (it runs ParseManifest, so JSON-schema and cross-field
// validation are applied). Useful for quick plugin discovery and validation.
func ReadManifest(ndpPath string) (*Manifest, error) {
// Open the zip archive
zr, err := zip.OpenReader(ndpPath)
if err != nil {
return nil, fmt.Errorf("opening package: %w", err)
}
defer zr.Close()
for _, f := range zr.File {
if f.Name == manifestFileName {
manifestBytes, err := readZipFile(f)
if err != nil {
return nil, fmt.Errorf("reading manifest: %w", err)
}
manifest, err := ParseManifest(manifestBytes)
if err != nil {
return nil, fmt.Errorf("parsing manifest: %w", err)
}
return manifest, nil
}
}
return nil, errors.New("package missing manifest.json")
}
// readZipFile reads the contents of a file from a zip archive.
func readZipFile(f *zip.File) ([]byte, error) {
rc, err := f.Open()
if err != nil {
return nil, err
}
defer rc.Close()
return io.ReadAll(rc)
}