diff --git a/core/plugins/manager.go b/core/plugins/manager.go index 00aefa3..f044f35 100644 --- a/core/plugins/manager.go +++ b/core/plugins/manager.go @@ -9,6 +9,7 @@ import ( "os/exec" "path/filepath" "runtime" + "slices" "strconv" "sync" "time" @@ -108,6 +109,40 @@ func (m *Manager) port(id string) int { return 0 } +// envAllowList is what a plugin gets by default. LookupEnv simply misses +// whichever names don't apply to the current OS, so no runtime.GOOS branch +// is needed: PATH lets the plugin's own entry command find its runtime/ +// tools; the rest are what node/python/go and Windows' network stack expect +// to find a home/cache/temp dir. +var envAllowList = []string{"PATH", "HOME", "USERPROFILE", "TMPDIR", "TEMP", "TMP", "SystemRoot"} + +func minimalEnv() []string { + env := make([]string, 0, len(envAllowList)) + for _, name := range envAllowList { + if v, ok := os.LookupEnv(name); ok { + env = append(env, name+"="+v) + } + } + return env +} + +// buildEnv assembles a plugin's child-process environment. A plugin that +// declares "env:full" in Manifest.Permissions inherits the full parent +// environment (today's behavior); everything else gets envAllowList only, +// so it can't read ambient secrets out of enowX's own environment unless it +// explicitly opts in. +func buildEnv(man *Manifest, id string, port, enowxPort int) []string { + env := minimalEnv() + if slices.Contains(man.Permissions, "env:full") { + env = os.Environ() + } + return append(env, + "PORT="+strconv.Itoa(port), + "ENOWX_PLUGIN_ID="+id, + "ENOWX_API=http://127.0.0.1:"+strconv.Itoa(enowxPort), + ) +} + // Start spawns a plugin's sidecar (no-op for static plugins). func (m *Manager) Start(id string) error { if err := m.authorized(); err != nil { @@ -153,11 +188,7 @@ func (m *Manager) Start(id string) error { } cmd := exec.Command(bin, args...) cmd.Dir = filepath.Join(m.dir, id) - cmd.Env = append(os.Environ(), - "PORT="+strconv.Itoa(port), - "ENOWX_PLUGIN_ID="+id, - "ENOWX_API=http://127.0.0.1:"+strconv.Itoa(m.enowxPort), - ) + cmd.Env = buildEnv(man, id, port, m.enowxPort) hideWindow(cmd) pr := &proc{cmd: cmd, port: port, running: true} diff --git a/core/plugins/manager_test.go b/core/plugins/manager_test.go new file mode 100644 index 0000000..59f2472 --- /dev/null +++ b/core/plugins/manager_test.go @@ -0,0 +1,57 @@ +package plugins + +import ( + "slices" + "strings" + "testing" +) + +func hasEnvKey(env []string, key string) bool { + prefix := key + "=" + return slices.ContainsFunc(env, func(kv string) bool { return strings.HasPrefix(kv, prefix) }) +} + +func TestMinimalEnv(t *testing.T) { + t.Setenv("PATH", "/usr/bin") + t.Setenv("ENOWX_TEST_SECRET", "top-secret") + + env := minimalEnv() + + if !hasEnvKey(env, "PATH") { + t.Errorf("minimalEnv() = %v, want PATH present", env) + } + if hasEnvKey(env, "ENOWX_TEST_SECRET") { + t.Errorf("minimalEnv() = %v, leaked a non-allow-listed var", env) + } +} + +func TestBuildEnv(t *testing.T) { + t.Setenv("ENOWX_TEST_SECRET", "top-secret") + + t.Run("default scopes down to the allow-list", func(t *testing.T) { + man := &Manifest{ID: "demo"} + env := buildEnv(man, "demo", 5555, 1430) + + if hasEnvKey(env, "ENOWX_TEST_SECRET") { + t.Errorf("buildEnv() leaked ENOWX_TEST_SECRET into a plugin without env:full") + } + if !hasEnvKey(env, "PORT") { + t.Errorf("buildEnv() = %v, missing PORT", env) + } + if !hasEnvKey(env, "ENOWX_PLUGIN_ID") { + t.Errorf("buildEnv() = %v, missing ENOWX_PLUGIN_ID", env) + } + if !hasEnvKey(env, "ENOWX_API") { + t.Errorf("buildEnv() = %v, missing ENOWX_API", env) + } + }) + + t.Run("env:full inherits the parent environment", func(t *testing.T) { + man := &Manifest{ID: "demo", Permissions: []string{"env:full"}} + env := buildEnv(man, "demo", 5555, 1430) + + if !hasEnvKey(env, "ENOWX_TEST_SECRET") { + t.Errorf("buildEnv() with env:full did not inherit ENOWX_TEST_SECRET") + } + }) +} diff --git a/core/plugins/manifest.go b/core/plugins/manifest.go index 3c4b81b..33148e3 100644 --- a/core/plugins/manifest.go +++ b/core/plugins/manifest.go @@ -18,12 +18,12 @@ type Manifest struct { ID string `json:"id"` Name string `json:"name"` Description string `json:"description"` - Icon string `json:"icon"` // lucide name, or "icon.png" in the folder - Runtime string `json:"runtime"` // go | python | node | static - Entry string `json:"entry"` // command entry (ignored for static) - UI string `json:"ui"` // path served at /plugins// (default public/index.html) - Version string `json:"version"` // semver, bumped on each marketplace re-publish - Permissions []string `json:"permissions,omitempty"` + Icon string `json:"icon"` // lucide name, or "icon.png" in the folder + Runtime string `json:"runtime"` // go | python | node | static + Entry string `json:"entry"` // command entry (ignored for static) + UI string `json:"ui"` // path served at /plugins// (default public/index.html) + Version string `json:"version"` // semver, bumped on each marketplace re-publish + Permissions []string `json:"permissions,omitempty"` // "env:full" inherits the parent process env; default is a minimal allow-list (plugins.envAllowList) } var idRe = regexp.MustCompile(`^[a-z0-9][a-z0-9-]{0,47}$`)