2026-04-06 10:30:18 +00:00
|
|
|
// Pass 4 — loader symlink rejection tests.
|
|
|
|
|
//
|
|
|
|
|
// Locks in the Pass 3 defense against malicious plugin .zip packages that
|
|
|
|
|
// ship symlinks to host filesystem paths. http.ServeFile follows symlinks
|
|
|
|
|
// transparently, so the only safe time to reject them is at install /
|
|
|
|
|
// directory-scan time.
|
|
|
|
|
package plugin
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
"os"
|
|
|
|
|
"path/filepath"
|
|
|
|
|
"runtime"
|
|
|
|
|
"testing"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
func TestRejectSymlinksUnderClean(t *testing.T) {
|
|
|
|
|
dir := t.TempDir()
|
|
|
|
|
if err := os.WriteFile(filepath.Join(dir, "regular.txt"), []byte("ok"), 0o644); err != nil {
|
|
|
|
|
t.Fatal(err)
|
|
|
|
|
}
|
|
|
|
|
if err := os.Mkdir(filepath.Join(dir, "subdir"), 0o755); err != nil {
|
|
|
|
|
t.Fatal(err)
|
|
|
|
|
}
|
|
|
|
|
if err := os.WriteFile(filepath.Join(dir, "subdir", "nested.txt"), []byte("ok"), 0o644); err != nil {
|
|
|
|
|
t.Fatal(err)
|
|
|
|
|
}
|
|
|
|
|
if err := rejectSymlinksUnder(dir); err != nil {
|
|
|
|
|
t.Fatalf("clean directory should pass, got: %v", err)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestRejectSymlinksUnderFindsSymlink(t *testing.T) {
|
|
|
|
|
if runtime.GOOS == "windows" {
|
|
|
|
|
t.Skip("symlink creation requires elevated privileges on Windows")
|
|
|
|
|
}
|
|
|
|
|
dir := t.TempDir()
|
|
|
|
|
if err := os.WriteFile(filepath.Join(dir, "regular.txt"), []byte("ok"), 0o644); err != nil {
|
|
|
|
|
t.Fatal(err)
|
|
|
|
|
}
|
|
|
|
|
// Create an evil symlink pointing at /etc/passwd.
|
|
|
|
|
link := filepath.Join(dir, "evil.html")
|
|
|
|
|
if err := os.Symlink("/etc/passwd", link); err != nil {
|
|
|
|
|
t.Skipf("symlink creation failed (likely unsupported FS): %v", err)
|
|
|
|
|
}
|
|
|
|
|
if err := rejectSymlinksUnder(dir); err == nil {
|
|
|
|
|
t.Fatal("expected rejectSymlinksUnder to refuse the symlink")
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestRejectSymlinksUnderFindsNestedSymlink(t *testing.T) {
|
|
|
|
|
if runtime.GOOS == "windows" {
|
|
|
|
|
t.Skip("symlink creation requires elevated privileges on Windows")
|
|
|
|
|
}
|
|
|
|
|
dir := t.TempDir()
|
|
|
|
|
sub := filepath.Join(dir, "assets")
|
|
|
|
|
if err := os.MkdirAll(sub, 0o755); err != nil {
|
|
|
|
|
t.Fatal(err)
|
|
|
|
|
}
|
|
|
|
|
link := filepath.Join(sub, "leak")
|
|
|
|
|
if err := os.Symlink("/etc/passwd", link); err != nil {
|
|
|
|
|
t.Skipf("symlink creation failed: %v", err)
|
|
|
|
|
}
|
|
|
|
|
if err := rejectSymlinksUnder(dir); err == nil {
|
|
|
|
|
t.Fatal("expected nested symlink to be rejected")
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-19 16:32:07 +02:00
|
|
|
// OC-0165: a single malformed plugin directory must not blank out every
|
|
|
|
|
// other, otherwise-valid plugin in the scan. scanPluginDirectory should skip
|
|
|
|
|
// the bad directory (recording its error) and still return the good one.
|
|
|
|
|
func TestScanPluginDirectory_SkipsBadPluginButReturnsGood(t *testing.T) {
|
|
|
|
|
root := t.TempDir()
|
|
|
|
|
|
|
|
|
|
// Good plugin: valid plugin.json + matching .wasm entrypoint.
|
|
|
|
|
goodDir := filepath.Join(root, "hello")
|
|
|
|
|
if err := os.MkdirAll(goodDir, 0o755); err != nil {
|
|
|
|
|
t.Fatal(err)
|
|
|
|
|
}
|
|
|
|
|
goodManifest := []byte(`{"name":"hello","version":"1.0.0","entrypoint":"hello.wasm"}`)
|
|
|
|
|
if err := os.WriteFile(filepath.Join(goodDir, "plugin.json"), goodManifest, 0o644); err != nil {
|
|
|
|
|
t.Fatal(err)
|
|
|
|
|
}
|
|
|
|
|
if err := os.WriteFile(filepath.Join(goodDir, "hello.wasm"), []byte("\x00asm"), 0o644); err != nil {
|
|
|
|
|
t.Fatal(err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Broken plugin: malformed JSON (trailing comma).
|
|
|
|
|
brokenDir := filepath.Join(root, "broken")
|
|
|
|
|
if err := os.MkdirAll(brokenDir, 0o755); err != nil {
|
|
|
|
|
t.Fatal(err)
|
|
|
|
|
}
|
|
|
|
|
brokenManifest := []byte(`{"name":"broken","version":"1.0.0","entrypoint":"broken.wasm",}`)
|
|
|
|
|
if err := os.WriteFile(filepath.Join(brokenDir, "plugin.json"), brokenManifest, 0o644); err != nil {
|
|
|
|
|
t.Fatal(err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
found, err := scanPluginDirectory(root)
|
|
|
|
|
if err == nil {
|
|
|
|
|
t.Fatal("expected scanPluginDirectory to report an error for the broken plugin")
|
|
|
|
|
}
|
|
|
|
|
if len(found) != 1 {
|
|
|
|
|
t.Fatalf("scanPluginDirectory returned %d plugins, want 1 (the good one survived alongside the reported error); got %+v", len(found), found)
|
|
|
|
|
}
|
|
|
|
|
if found[0].Manifest.Name != "hello" {
|
|
|
|
|
t.Fatalf("scanPluginDirectory returned plugin %q, want \"hello\"", found[0].Manifest.Name)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-06 10:30:18 +00:00
|
|
|
func TestScanPluginDirectoryRejectsSymlinkEntrypoint(t *testing.T) {
|
|
|
|
|
if runtime.GOOS == "windows" {
|
|
|
|
|
t.Skip("symlink creation requires elevated privileges on Windows")
|
|
|
|
|
}
|
|
|
|
|
root := t.TempDir()
|
|
|
|
|
pluginDir := filepath.Join(root, "evil")
|
|
|
|
|
if err := os.MkdirAll(pluginDir, 0o755); err != nil {
|
|
|
|
|
t.Fatal(err)
|
|
|
|
|
}
|
|
|
|
|
// Manifest claims hello.wasm; we'll make hello.wasm a symlink.
|
|
|
|
|
manifest := []byte(`{
|
|
|
|
|
"name": "evil",
|
|
|
|
|
"version": "0.1.0",
|
|
|
|
|
"entrypoint": "hello.wasm"
|
|
|
|
|
}`)
|
|
|
|
|
if err := os.WriteFile(filepath.Join(pluginDir, "plugin.json"), manifest, 0o644); err != nil {
|
|
|
|
|
t.Fatal(err)
|
|
|
|
|
}
|
|
|
|
|
// Create a real target then symlink to it (so the target exists; the
|
|
|
|
|
// symlink itself is what we want to reject).
|
|
|
|
|
target := filepath.Join(root, "target.bin")
|
|
|
|
|
if err := os.WriteFile(target, []byte("\x00asm"), 0o644); err != nil {
|
|
|
|
|
t.Fatal(err)
|
|
|
|
|
}
|
|
|
|
|
if err := os.Symlink(target, filepath.Join(pluginDir, "hello.wasm")); err != nil {
|
|
|
|
|
t.Skipf("symlink creation failed: %v", err)
|
|
|
|
|
}
|
|
|
|
|
_, err := scanPluginDirectory(root)
|
|
|
|
|
if err == nil {
|
|
|
|
|
t.Fatal("scanPluginDirectory should reject plugin with symlinked entrypoint")
|
|
|
|
|
}
|
|
|
|
|
}
|