security: стабилизационный аудит Lua plugin system

Исправления:
- Install: идемпотентность (no duplicates in InstalledPlugins)
- ReloadPlugins: StopSchedulers + CallShutdownHooks перед CloseRuntimes
- StopSchedulers: обнуление scheduler=nil после остановки
- Scheduler.Stop: обнуление tasks после wg.Wait
- Lua sandbox: блокировка package.loadlib/seeall/preload/loaders/loaded/path/cpath/config/searchpath
- Lua sandbox: блокировка load (глобальная функция)
- CallPluginFunction: валидация funcName (regex [a-zA-Z_][a-zA-Z0-9_]*, max 3 segments)
- CallPluginFunction: убрана строковая сборка Lua-кодa, вызов через PCall напрямую
- PluginPage.svelte: проверка e.source === iframeEl.contentWindow
- PluginPage.svelte: type checking для msg.source, msg.action

Тесты:
- security_test.go: 18 новых тестов (sandbox, lifecycle, validation)
- Все существующие тесты проходят

Документация:
- docs/plugins-security.md: модель безопасности, sandbox, протокол, lifecycle
This commit is contained in:
2026-06-07 19:19:44 +08:00
parent c443ca23c5
commit 4df83cd361
9 changed files with 1305 additions and 54 deletions
+102 -47
View File
@@ -6,9 +6,11 @@ import (
"log"
"os"
"path/filepath"
"regexp"
"strings"
"verstak/internal/core/config"
"verstak/internal/core/plugins"
lua "github.com/yuin/gopher-lua"
)
@@ -204,13 +206,32 @@ func (a *App) ListSystemViewsWithPlugins() []SystemViewDTO {
return base
}
// validLuaIdent matches a safe Lua identifier segment: [a-zA-Z_][a-zA-Z0-9_]*
var validLuaIdent = regexp.MustCompile(`^[a-zA-Z_][a-zA-Z0-9_]*$`)
// CallPluginFunction calls a global Lua function on an active plugin.
// The funcName can use dots: "calendar.create_event" → _G.calendar.create_event
// Only alphanumeric identifiers with underscores are allowed (no Lua injection).
// Returns JSON string or error.
func (a *App) CallPluginFunction(pluginName, funcName string, paramsJSON string) (string, error) {
if a.plugins == nil {
return "", fmt.Errorf("plugin manager not ready")
}
// Validate funcName: only [a-zA-Z0-9_.]+ allowed, each segment must be valid ident
if funcName == "" {
return "", fmt.Errorf("funcName is empty")
}
segments := strings.Split(funcName, ".")
if len(segments) > 3 {
return "", fmt.Errorf("funcName %q too deep (max 2 dots)", funcName)
}
for _, seg := range segments {
if !validLuaIdent.MatchString(seg) {
return "", fmt.Errorf("funcName %q contains invalid segment %q", funcName, seg)
}
}
for _, p := range a.plugins.Plugins() {
if p.Meta.Name != pluginName || !p.Active {
continue
@@ -219,73 +240,107 @@ func (a *App) CallPluginFunction(pluginName, funcName string, paramsJSON string)
if vm == nil {
continue
}
// Resolve dotted path: "calendar.create_event" → _G.calendar.create_event
parts := strings.SplitN(funcName, ".", 2)
var luaFn string
if len(parts) == 2 {
luaFn = fmt.Sprintf("_G.%s['%s']", parts[0], parts[1])
// Resolve the function via _G to avoid string-based code generation
var fn lua.LValue
if len(segments) == 1 {
fn = vm.LState().GetGlobal(segments[0])
} else {
luaFn = fmt.Sprintf("_G['%s']", funcName)
}
// Parse params
var params interface{}
if paramsJSON != "" && paramsJSON != "{}" {
if err := json.Unmarshal([]byte(paramsJSON), &params); err != nil {
params = paramsJSON
}
}
// Convert params to Lua value
var luaArg lua.LValue
switch v := params.(type) {
case nil:
luaArg = lua.LNil
case string:
luaArg = lua.LString(v)
case float64:
luaArg = lua.LNumber(v)
case bool:
luaArg = lua.LBool(v)
case map[string]interface{}:
tbl := vm.LState().NewTable()
for key, val := range v {
switch sv := val.(type) {
case string:
tbl.RawSetString(key, lua.LString(sv))
case float64:
tbl.RawSetString(key, lua.LNumber(sv))
case bool:
tbl.RawSetString(key, lua.LBool(sv))
// Walk the dotted path: _G[seg1][seg2]...
tbl := vm.LState().GetGlobal(segments[0])
for i := 1; i < len(segments); i++ {
if t, ok := tbl.(*lua.LTable); ok {
tbl = t.RawGetString(segments[i])
} else {
tbl = lua.LNil
break
}
}
luaArg = tbl
default:
luaArg = lua.LString(paramsJSON)
fn = tbl
}
var script string
if luaArg == lua.LNil {
script = fmt.Sprintf("return %s()", luaFn)
} else {
script = fmt.Sprintf("return %s(%s)", luaFn, luaArg.String())
if fn == lua.LNil {
return "", fmt.Errorf("function %q not found in plugin %q", funcName, pluginName)
}
if _, ok := fn.(*lua.LFunction); !ok {
return "", fmt.Errorf("%q is not a function in plugin %q", funcName, pluginName)
}
result, err := vm.DoString(script)
// Parse params into Lua value
luaArg, err := parseParamsToLua(vm, paramsJSON)
if err != nil {
return "", fmt.Errorf("parse params: %w", err)
}
// Call the function directly via PCall (no string-based code generation)
vm.LState().Push(fn)
if luaArg != nil {
vm.LState().Push(luaArg)
}
nargs := 0
if luaArg != nil {
nargs = 1
}
if err := vm.LState().PCall(nargs, 1, nil); err != nil {
return "", fmt.Errorf("call %s: %w", funcName, err)
}
return result, nil
ret := vm.LState().Get(-1)
vm.LState().Pop(1)
return ret.String(), nil
}
return "", fmt.Errorf("plugin %q not active or not found", pluginName)
}
// parseParamsToLua converts a JSON params string to a lua.LValue.
// Empty or "{}" → nil (no argument).
func parseParamsToLua(vm *plugins.LuaVM, paramsJSON string) (lua.LValue, error) {
if paramsJSON == "" || paramsJSON == "{}" {
return nil, nil
}
var params interface{}
if err := json.Unmarshal([]byte(paramsJSON), &params); err != nil {
return nil, fmt.Errorf("invalid JSON params: %w", err)
}
return goToLua(vm.LState(), params), nil
}
// goToLua converts a Go interface{} to a lua.LValue.
func goToLua(L *lua.LState, v interface{}) lua.LValue {
switch val := v.(type) {
case nil:
return lua.LNil
case string:
return lua.LString(val)
case float64:
return lua.LNumber(val)
case bool:
return lua.LBool(val)
case map[string]interface{}:
tbl := L.NewTable()
for k, v := range val {
tbl.RawSetString(k, goToLua(L, v))
}
return tbl
case []interface{}:
tbl := L.NewTable()
for i, v := range val {
tbl.RawSetInt(i+1, goToLua(L, v))
}
return tbl
default:
return lua.LString(fmt.Sprintf("%v", v))
}
}
// ReloadPlugins re-scans the plugins directory and re-initializes runtimes.
func (a *App) ReloadPlugins() error {
if a.plugins == nil {
return fmt.Errorf("plugin manager not ready")
}
log.Print("[plugins] reload requested")
// Fully stop runtimes: schedulers first (they depend on VMs), then VMs
a.plugins.StopSchedulers()
a.plugins.CallShutdownHooks()
a.plugins.CloseRuntimes()
a.plugins.Discover()
+1 -1
View File
@@ -19,7 +19,7 @@
background: #13131f;
}
</style>
<script type="module" crossorigin src="/assets/main-BOc2ygMG.js"></script>
<script type="module" crossorigin src="/assets/main-9sLWbwBV.js"></script>
<link rel="stylesheet" crossorigin href="/assets/main-Cjkp2F09.css">
</head>
<body>