Smail/server/hooks/base_test.go

351 lines
11 KiB
Go

package hooks
import (
"context"
"encoding/json"
"git.pcux.cn/xuans/Smail/config"
"git.pcux.cn/xuans/Smail/db/migration"
"git.pcux.cn/xuans/Smail/hooks/framework"
pluginservice "git.pcux.cn/xuans/Smail/services/plugin"
"git.pcux.cn/xuans/Smail/utils/credential"
_ "modernc.org/sqlite"
"os"
"path/filepath"
"runtime"
"strings"
"testing"
"time"
"xorm.io/xorm"
)
func TestPluginAllowListRejectsUnsafeNames(t *testing.T) {
allowed := pluginAllowList([]string{"safe-plugin", "safe-plugin", "../outside", "nested/file", "*", ""})
if len(allowed) != 1 || !allowed["safe-plugin"] {
t.Fatalf("unexpected allow list: %#v", allowed)
}
}
func TestIsPluginExecutable(t *testing.T) {
if runtime.GOOS == "windows" {
if !isPluginExecutable("safe.exe", 0600) || isPluginExecutable("safe", 0600) {
t.Fatal("Windows executable validation is incorrect")
}
return
}
if !isPluginExecutable("safe", os.FileMode(0700)) || isPluginExecutable("safe.txt", os.FileMode(0700)) || isPluginExecutable("safe", os.FileMode(0600)) {
t.Fatal("Unix executable validation is incorrect")
}
}
func TestSecurePluginFile(t *testing.T) {
if runtime.GOOS == "windows" {
return
}
if !securePluginFile(0700) || securePluginFile(0775) || securePluginFile(0702) || securePluginFile(os.ModeSetuid|0700) || securePluginFile(os.ModeSetgid|0700) {
t.Fatal("plugin file permission validation is incorrect")
}
}
func TestMinimalPluginEnvUsesAllowList(t *testing.T) {
t.Setenv("SMAIL_PLUGIN_SECRET", "should-not-be-forwarded")
t.Setenv("DB_PASSWORD", "should-not-be-forwarded")
env := minimalPluginEnv("C:/smail")
values := make(map[string]string, len(env))
for _, item := range env {
parts := strings.SplitN(item, "=", 2)
if len(parts) == 2 {
values[parts[0]] = parts[1]
}
}
if values["SMAIL_ROOT"] != "C:/smail" || values["SMAIL_PLUGIN"] != "1" {
t.Fatalf("plugin identity environment is incorrect: %#v", values)
}
for _, name := range []string{"SMAIL_PLUGIN_SECRET", "DB_PASSWORD", "HOME"} {
if _, ok := values[name]; ok {
t.Fatalf("sensitive environment variable was forwarded: %s", name)
}
}
}
func TestProductionPluginIsolationPolicy(t *testing.T) {
if runtime.GOOS != "windows" {
t.Skip("Windows production policy test")
}
if _, err := newPluginLaunchSpec("plugin.exe", ".", "plugin.exe", "1.socket", nil); err == nil {
t.Fatal("Windows production plugin isolation must fail closed")
}
}
func TestPersistPluginConfigReplacesFileAtomically(t *testing.T) {
workingDirectory, err := os.Getwd()
if err != nil {
t.Fatal(err)
}
root := t.TempDir()
if err = os.Chdir(root); err != nil {
t.Fatal(err)
}
t.Cleanup(func() {
_ = os.Chdir(workingDirectory)
ResetHooks()
})
if err = os.Mkdir("plugins", 0700); err != nil {
t.Fatal(err)
}
ResetHooks()
if !RegisterHook("spam_block", &HookSender{name: "spam_block.exe"}) {
t.Fatal("plugin hook registration failed")
}
for _, payload := range []string{`{"url":"https://model.example.com"}`, `{"url":"https://model.example.com/v2"}`} {
if err = PersistPluginConfig("spam_block", payload); err != nil {
t.Fatal(err)
}
}
path := filepath.Join(root, "plugins", "spam_block_config.json")
data, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
if string(data) != `{"url":"https://model.example.com/v2"}` {
t.Fatalf("plugin configuration content = %q", data)
}
info, err := os.Stat(path)
if err != nil {
t.Fatal(err)
}
if runtime.GOOS != "windows" && info.Mode().Perm() != 0600 {
t.Fatalf("plugin configuration permissions = %o, want 600", info.Mode().Perm())
}
}
func TestRegisterHookRejectsUnsafeNames(t *testing.T) {
ResetHooks()
t.Cleanup(ResetHooks)
for _, name := range []string{"../unsafe", "nested/name", `nested\name`, "name with space", strings.Repeat("a", 65)} {
if RegisterHook(name, &HookSender{}) {
t.Fatalf("unsafe hook name was accepted: %q", name)
}
}
if !RegisterHook("safe-plugin_1", &HookSender{}) {
t.Fatal("safe hook name was rejected")
}
}
func TestPluginDefinitionsFromRuntimeUsesEnabledLogicalIDs(t *testing.T) {
runtimeConfig := config.RuntimeConfig{
Plugins: config.PluginsSectionConfig{Plugins: map[string]config.PluginInstance{
"spam_block": {Enabled: true},
"telegram_push": {Enabled: false},
}},
}
definitions, err := pluginDefinitionsFromRuntime(runtimeConfig)
if err != nil {
t.Fatal(err)
}
if len(definitions) != 1 || definitions[0].LogicalID != "spam_block" {
t.Fatalf("definitions = %+v", definitions)
}
}
func TestPluginRuntimeConfigRejectsStaleOrEscalatedBinding(t *testing.T) {
base := config.PluginInstance{
Enabled: true,
Permissions: config.PluginPermissions{
Capabilities: []string{"network"},
Network: []config.PluginNetworkTarget{{Scheme: "https", Host: "api.telegram.org", Port: 443}},
},
}
tests := []struct {
name string
mutate func(*config.PluginInstance)
}{
{name: "disabled", mutate: func(instance *config.PluginInstance) { instance.Enabled = false }},
{name: "extra capability", mutate: func(instance *config.PluginInstance) {
instance.Permissions.Capabilities = append(instance.Permissions.Capabilities, "filesystem")
}},
{name: "legacy secret", mutate: func(instance *config.PluginInstance) {
instance.Secret.Ciphertext = "encrypted"
instance.Secret.Version = 1
instance.Secret.UpdatedAt = time.Now()
}},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
instance := base
test.mutate(&instance)
if _, err := pluginRuntimeConfig("telegram_push", 1, instance); err == nil {
t.Fatal("unsafe plugin binding was accepted")
}
})
}
}
func TestPluginRuntimeConfigBuildsOnlyCatalogCapabilities(t *testing.T) {
instance := config.PluginInstance{
Enabled: true,
Settings: json.RawMessage(`{"timeout":10}`),
Permissions: config.PluginPermissions{
Capabilities: []string{"network"},
Network: []config.PluginNetworkTarget{{Scheme: "https", Host: "api.telegram.org", Port: 443}},
},
}
payload, err := pluginRuntimeConfig("telegram_push", 7, instance)
if err != nil {
t.Fatal(err)
}
if payload.PluginID != "telegram_push" || payload.Generation != 7 || len(payload.Capabilities) != 1 || payload.Capabilities[0] != "network" {
t.Fatalf("unexpected capabilities: %+v", payload.Capabilities)
}
if len(payload.Network) != 1 || payload.Network[0] != (framework.PluginNetworkTarget{Scheme: "https", Host: "api.telegram.org", Port: 443}) {
t.Fatalf("unexpected network targets: %+v", payload.Network)
}
if string(payload.Settings) != `{"timeout":10}` {
t.Fatalf("unexpected settings: %s", payload.Settings)
}
}
func TestPluginRuntimeConfigRejectsMissingGeneration(t *testing.T) {
instance := config.PluginInstance{
Enabled: true,
Permissions: config.PluginPermissions{
Capabilities: []string{"network"},
},
}
if _, err := pluginRuntimeConfig("spam_block", 0, instance); err == nil {
t.Fatal("zero plugin configuration generation was accepted")
}
}
func TestPluginRuntimeConfigIsolatesPluginSecrets(t *testing.T) {
service, engine := newHookPluginConfigService(t)
_ = engine
previous := pluginConfigService
SetPluginConfigService(service)
t.Cleanup(func() { SetPluginConfigService(previous) })
for _, entry := range []struct {
pluginID string
field string
value string
}{
{pluginID: "wechat_push", field: "appSecret", value: "wechat-secret-for-test"},
{pluginID: "telegram_push", field: "botToken", value: "telegram-secret-for-test"},
} {
if _, err := service.ChangeSecret(entry.pluginID, entry.field, config.SecretChange{
Operation: config.SecretReplace,
Value: &config.SecretInput{Plaintext: entry.value},
}, 0); err != nil {
t.Fatal(err)
}
}
wechat, err := pluginRuntimeConfig("wechat_push", 3, config.PluginInstance{
Enabled: true,
Settings: json.RawMessage(`{"appId":"app-id","templateId":"template-id","userId":"user-id","webDomain":"mail.example.com"}`),
Permissions: config.PluginPermissions{
Capabilities: []string{"network"},
Network: []config.PluginNetworkTarget{{Scheme: "https", Host: "api.weixin.qq.com", Port: 443}},
},
})
if err != nil {
t.Fatal(err)
}
if len(wechat.Secrets) != 1 || wechat.Secrets["appSecret"] != "wechat-secret-for-test" {
t.Fatalf("unexpected WeChat secrets: %#v", wechat.Secrets)
}
if _, exists := wechat.Secrets["botToken"]; exists {
t.Fatal("Telegram secret was included in WeChat runtime configuration")
}
wechatData, err := json.Marshal(wechat)
if err != nil {
t.Fatal(err)
}
if strings.Contains(string(wechatData), "telegram-secret-for-test") {
t.Fatal("Telegram secret appeared in WeChat runtime configuration")
}
telegram, err := pluginRuntimeConfig("telegram_push", 4, config.PluginInstance{
Enabled: true,
Settings: json.RawMessage(`{"timeout":10}`),
Permissions: config.PluginPermissions{
Capabilities: []string{"network"},
Network: []config.PluginNetworkTarget{{Scheme: "https", Host: "api.telegram.org", Port: 443}},
},
})
if err != nil {
t.Fatal(err)
}
if len(telegram.Secrets) != 1 || telegram.Secrets["botToken"] != "telegram-secret-for-test" {
t.Fatalf("unexpected Telegram secrets: %#v", telegram.Secrets)
}
if _, exists := telegram.Secrets["appSecret"]; exists {
t.Fatal("WeChat secret was included in Telegram runtime configuration")
}
telegramData, err := json.Marshal(telegram)
if err != nil {
t.Fatal(err)
}
if strings.Contains(string(telegramData), "wechat-secret-for-test") {
t.Fatal("WeChat secret appeared in Telegram runtime configuration")
}
}
func TestPluginCapabilitiesMatchCatalogStrictly(t *testing.T) {
definition, ok := pluginservice.Lookup("telegram_push")
if !ok {
t.Fatal("telegram plugin definition is missing")
}
valid := framework.Capabilities{
PluginID: "telegram_push",
Capabilities: []string{"network", "userSettings", "settingsHtml"},
Settings: []string{"proxy", "timeout", "debug", "webDomain", "httpsEnabled"},
Protocol: 1,
}
if !pluginCapabilitiesMatch(definition, valid) {
t.Fatal("valid plugin capability handshake was rejected")
}
valid.Capabilities[0] = "filesystem"
if pluginCapabilitiesMatch(definition, valid) {
t.Fatal("unexpected plugin capability was accepted")
}
}
func newHookPluginConfigService(t *testing.T) (*pluginservice.ConfigService, *xorm.Engine) {
t.Helper()
engine, err := xorm.NewEngine("sqlite", filepath.Join(t.TempDir(), "hook-plugin-config.db"))
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = engine.Close() })
runner := migration.Runner{DB: engine.DB().DB, Dialect: migration.DialectSQLite, Migrations: migration.DefaultMigrations()}
if _, err = runner.Run(context.Background()); err != nil {
t.Fatal(err)
}
vault, err := credential.NewVault(t.TempDir())
if err != nil {
t.Fatal(err)
}
service, err := pluginservice.NewConfigService(engine, vault)
if err != nil {
t.Fatal(err)
}
return service, engine
}
func TestLegacyPluginEnvironmentDoesNotForwardWeChatSecret(t *testing.T) {
previous := config.CurrentConfig()
config.PublishConfigSnapshot(&config.Config{
WeChatPushAppId: "app-id",
WeChatPushSecret: "secret-must-not-be-forwarded",
WeChatPushTemplateId: "template-id",
WeChatPushUserId: "user-id",
})
t.Cleanup(func() { config.PublishConfigSnapshot(previous) })
env := pluginEnvironment("C:/smail", "wechat_push")
for _, item := range env {
if strings.HasPrefix(item, "SMAIL_WECHAT_SECRET=") {
t.Fatal("legacy WeChat secret was forwarded to plugin environment")
}
}
}