Smail/backend/hooks/plugin_isolation_linux.go
smail f60d3b0880
Some checks are pending
Security Checks / security (push) Waiting to run
整理前后端与测试构建目录并完善配置和安全引导
统一 frontend、backend、test、test-log 和 out 目录,更新构建、部署及测试入口;纳入配置管理兼容与首次安全引导修复;排除本机配置、密钥和测试产物。
2026-09-05 19:19:52 +08:00

241 lines
6.9 KiB
Go

//go:build linux
package hooks
import (
"errors"
"fmt"
"io"
"net"
"os"
"path/filepath"
"strconv"
"syscall"
"golang.org/x/sys/unix"
)
const (
pluginMemoryLimit = 512 << 20
pluginFileLimit = 128
pluginProcessLimit = 32
)
func newPluginLaunchSpec(pluginPath, root, name, socketName string, env []string) (*pluginLaunchSpec, error) {
if os.Geteuid() != 0 {
return nil, errors.New("the Smail process must run as root to isolate plugins")
}
uid, err := pluginIdentity("SMAIL_PLUGIN_UID")
if err != nil {
return nil, err
}
gid, err := pluginIdentity("SMAIL_PLUGIN_GID")
if err != nil {
return nil, err
}
if uid == 0 || gid == 0 {
return nil, errors.New("the plugin identity must not be root")
}
runtimeDir, err := os.MkdirTemp("", "smail-plugin-")
if err != nil {
return nil, fmt.Errorf("create plugin sandbox: %w", err)
}
cleanup := func() { _ = os.RemoveAll(runtimeDir) }
if err := os.Chmod(runtimeDir, 0700); err != nil {
cleanup()
return nil, fmt.Errorf("secure plugin sandbox: %w", err)
}
if err := os.Chown(runtimeDir, uid, gid); err != nil {
cleanup()
return nil, fmt.Errorf("assign plugin sandbox identity: %w", err)
}
sandboxPluginPath := filepath.Join(runtimeDir, name)
if err := copyPluginExecutable(pluginPath, sandboxPluginPath, uid, gid); err != nil {
cleanup()
return nil, err
}
if err := copyPluginConfig(root, runtimeDir, name, uid, gid); err != nil {
cleanup()
return nil, err
}
isolatedEnv := isolatedPluginEnv(env)
abstractSocket := "@" + socketName
listener, err := net.ListenUnix("unix", &net.UnixAddr{Name: abstractSocket, Net: "unix"})
if err != nil {
cleanup()
return nil, fmt.Errorf("create plugin listener: %w", err)
}
listenerFile, err := listener.File()
if closeErr := listener.Close(); closeErr != nil && err == nil {
err = closeErr
}
if err != nil {
cleanup()
return nil, fmt.Errorf("prepare plugin listener: %w", err)
}
cloneFlags := uintptr(unix.CLONE_NEWNET)
return &pluginLaunchSpec{
path: "/" + name,
args: []string{name, "fd:3"},
socketPath: abstractSocket,
runtimeDir: runtimeDir,
parentFiles: []*os.File{listenerFile},
procAttr: &os.ProcAttr{
Dir: "/",
Env: isolatedEnv,
Files: []*os.File{
os.Stdin,
os.Stdout,
os.Stderr,
listenerFile,
},
Sys: &syscall.SysProcAttr{
Chroot: runtimeDir,
Credential: &syscall.Credential{Uid: uint32(uid), Gid: uint32(gid), NoSetGroups: true},
Cloneflags: cloneFlags,
Pdeathsig: syscall.SIGKILL,
Setsid: true,
},
},
}, nil
}
func copyPluginConfig(root, runtimeDir, name string, uid, gid int) error {
configPath := filepath.Join(root, pluginConfigFileName(name))
info, err := os.Lstat(configPath)
if os.IsNotExist(err) {
return nil
}
if err != nil || info.Mode()&os.ModeSymlink != 0 || !info.Mode().IsRegular() || info.Mode().Perm()&0022 != 0 || info.Mode()&os.ModeSetuid != 0 || info.Mode()&os.ModeSetgid != 0 || info.Size() > maxPluginConfigBytes {
return errors.New("plugin configuration file is unsafe")
}
if !securePluginFileOwner(info) {
return errors.New("plugin configuration file owner is unsafe")
}
configDir := filepath.Join(runtimeDir, "plugins")
if err := os.Mkdir(configDir, 0700); err != nil {
return fmt.Errorf("create plugin configuration directory: %w", err)
}
if err := os.Chown(configDir, uid, gid); err != nil {
return fmt.Errorf("assign plugin configuration directory identity: %w", err)
}
return copyPluginFile(configPath, filepath.Join(configDir, filepath.Base(configPath)), uid, gid, 0600)
}
func copyPluginFile(sourcePath, targetPath string, uid, gid int, mode os.FileMode) error {
source, err := os.Open(sourcePath)
if err != nil {
return fmt.Errorf("open plugin configuration: %w", err)
}
defer source.Close()
target, err := os.OpenFile(targetPath, os.O_WRONLY|os.O_CREATE|os.O_EXCL, mode)
if err != nil {
return fmt.Errorf("create sandbox plugin configuration: %w", err)
}
written, err := io.Copy(target, io.LimitReader(source, maxPluginConfigBytes+1))
if err != nil {
_ = target.Close()
_ = os.Remove(targetPath)
return fmt.Errorf("copy plugin configuration: %w", err)
}
if err := target.Close(); err != nil {
_ = os.Remove(targetPath)
return fmt.Errorf("close sandbox plugin configuration: %w", err)
}
if written > maxPluginConfigBytes {
_ = os.Remove(targetPath)
return errors.New("plugin configuration exceeds the allowed size")
}
if err := os.Chmod(targetPath, mode); err != nil {
return fmt.Errorf("secure sandbox plugin configuration: %w", err)
}
if err := os.Chown(targetPath, uid, gid); err != nil {
return fmt.Errorf("assign sandbox plugin configuration identity: %w", err)
}
return nil
}
func pluginIdentity(name string) (int, error) {
value := os.Getenv(name)
if value == "" {
return 0, fmt.Errorf("%s must be configured for production plugins", name)
}
identity, err := strconv.Atoi(value)
if err != nil || identity < 1 || identity > 65534 {
return 0, fmt.Errorf("%s is invalid", name)
}
return identity, nil
}
func copyPluginExecutable(sourcePath, targetPath string, uid, gid int) error {
source, err := os.Open(sourcePath)
if err != nil {
return fmt.Errorf("open plugin executable: %w", err)
}
defer source.Close()
target, err := os.OpenFile(targetPath, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0700)
if err != nil {
return fmt.Errorf("create sandbox plugin executable: %w", err)
}
if _, err := io.Copy(target, source); err != nil {
_ = target.Close()
return fmt.Errorf("copy plugin executable: %w", err)
}
if err := target.Sync(); err != nil {
_ = target.Close()
return fmt.Errorf("sync sandbox plugin executable: %w", err)
}
if err := target.Close(); err != nil {
return fmt.Errorf("close sandbox plugin executable: %w", err)
}
if err := os.Chmod(targetPath, 0700); err != nil {
return fmt.Errorf("secure sandbox plugin executable: %w", err)
}
if err := os.Chown(targetPath, uid, gid); err != nil {
return fmt.Errorf("assign sandbox plugin identity: %w", err)
}
return nil
}
func securePluginDirectoryOwner(root string) bool {
info, err := os.Lstat(root)
if err != nil {
return false
}
if info.Mode()&os.ModeSymlink != 0 {
return false
}
stat, ok := info.Sys().(*syscall.Stat_t)
return ok && stat.Uid == 0
}
func securePluginFileOwner(info os.FileInfo) bool {
stat, ok := info.Sys().(*syscall.Stat_t)
return ok && stat.Uid == 0
}
func applyPluginResourceLimits(process *os.Process) error {
if process == nil {
return errors.New("plugin process is unavailable")
}
limits := []struct {
resource int
value uint64
}{
{resource: unix.RLIMIT_AS, value: pluginMemoryLimit},
{resource: unix.RLIMIT_NOFILE, value: pluginFileLimit},
{resource: unix.RLIMIT_NPROC, value: pluginProcessLimit},
}
for _, limit := range limits {
rlimit := &unix.Rlimit{Cur: limit.value, Max: limit.value}
if err := unix.Prlimit(process.Pid, limit.resource, rlimit, nil); err != nil {
return fmt.Errorf("set plugin resource limit: %w", err)
}
}
return nil
}