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:
parent
c443ca23c5
commit
4df83cd361
|
|
@ -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), ¶ms); 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))
|
||||
}
|
||||
}
|
||||
luaArg = tbl
|
||||
default:
|
||||
luaArg = lua.LString(paramsJSON)
|
||||
}
|
||||
|
||||
var script string
|
||||
if luaArg == lua.LNil {
|
||||
script = fmt.Sprintf("return %s()", luaFn)
|
||||
// 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 {
|
||||
script = fmt.Sprintf("return %s(%s)", luaFn, luaArg.String())
|
||||
tbl = lua.LNil
|
||||
break
|
||||
}
|
||||
}
|
||||
fn = tbl
|
||||
}
|
||||
|
||||
result, err := vm.DoString(script)
|
||||
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)
|
||||
}
|
||||
|
||||
// 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), ¶ms); 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()
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,203 @@
|
|||
# Plugin Security Model
|
||||
|
||||
## Overview
|
||||
|
||||
Verstak's Lua plugin system is designed with a **sandbox-first** approach. Plugins run in isolated Lua 5.1 states with restricted access to the host system. This document defines the security boundaries.
|
||||
|
||||
## Lua Sandbox
|
||||
|
||||
### Blocked Global Functions
|
||||
|
||||
The following Lua globals are set to `nil` in every plugin VM:
|
||||
|
||||
| Function | Reason |
|
||||
|----------|--------|
|
||||
| `dofile` | Arbitrary file read/execute |
|
||||
| `loadfile` | Arbitrary file read |
|
||||
| `require` | Module loading (no filesystem access) |
|
||||
| `module` | Module system (deprecated, unsafe) |
|
||||
| `load` | Arbitrary code execution from string |
|
||||
| `rawequal` | Bypass metatable protections |
|
||||
| `rawget` | Bypass metatable protections |
|
||||
| `rawset` | Bypass metatable protections |
|
||||
| `rawlen` | Bypass metatable protections |
|
||||
| `setfenv` | Environment manipulation |
|
||||
| `getfenv` | Environment manipulation |
|
||||
|
||||
### Blocked Libraries
|
||||
|
||||
| Library | Status | Notes |
|
||||
|---------|--------|-------|
|
||||
| `io` | **Not opened** | No file I/O |
|
||||
| `debug` | **Not opened** | No debug introspection |
|
||||
| `coroutine` | **Not opened** | No coroutine creation |
|
||||
| `os` | **Restricted** | Only `clock`, `date`, `difftime`, `time`, `tmpname` |
|
||||
| `package` | **Restricted** | Only `seeall` is available; `loadlib`, `preload`, `loaders`, `loaded`, `path`, `cpath`, `config`, `searchpath` are blocked |
|
||||
|
||||
### Restricted `os.*` Functions
|
||||
|
||||
| Function | Status | Reason |
|
||||
|----------|--------|--------|
|
||||
| `os.clock` | ✅ Allowed | Safe timing |
|
||||
| `os.date` | ✅ Allowed | Safe date formatting |
|
||||
| `os.difftime` | ✅ Allowed | Safe time diff |
|
||||
| `os.time` | ✅ Allowed | Safe timestamp |
|
||||
| `os.tmpname` | ✅ Allowed | Safe temp name |
|
||||
| `os.execute` | ❌ Blocked | Shell command execution |
|
||||
| `os.exit` | ❌ Blocked | Process termination |
|
||||
| `os.remove` | ❌ Blocked | File deletion |
|
||||
| `os.rename` | ❌ Blocked | File renaming |
|
||||
| `os.setlocale` | ❌ Blocked | Locale manipulation |
|
||||
| `os.getenv` | ❌ Blocked | Environment variable access |
|
||||
|
||||
### Safe Libraries (Available)
|
||||
|
||||
- `string` — string manipulation
|
||||
- `table` — table operations
|
||||
- `math` — mathematical functions
|
||||
- `type`, `tostring`, `tonumber` — type operations
|
||||
- `pcall`, `xpcall` — protected calls
|
||||
- `error`, `assert` — error handling
|
||||
- `select`, `unpack` — argument manipulation
|
||||
- `pairs`, `ipairs`, `next` — iteration
|
||||
- `collectgarbage` — GC control
|
||||
|
||||
### Print Redirection
|
||||
|
||||
`print()` is redirected to Go's `log.Printf` with the `[lua]` prefix. Plugins cannot write to stdout directly.
|
||||
|
||||
## Plugin Function Call Security
|
||||
|
||||
### `CallPluginFunction` Validation
|
||||
|
||||
When the frontend calls a plugin function via `CallPluginFunction(pluginName, funcName, paramsJSON)`:
|
||||
|
||||
1. **funcName validation**: Only `[a-zA-Z_][a-zA-Z0-9_]*` segments separated by dots (max 2 dots, 3 segments)
|
||||
2. **No string-based code generation**: Functions are resolved via `_G` table walk, not `DoString`
|
||||
3. **Direct PCall**: Arguments are pushed as `lua.LValue` directly, not serialized to Lua code
|
||||
4. **Plugin must be active**: Only enabled plugins can receive calls
|
||||
|
||||
### Blocked funcName Patterns
|
||||
|
||||
```
|
||||
❌ "func;os.execute('id')" — semicolon injection
|
||||
❌ "func..name" — empty segment
|
||||
❌ "func[name]" — bracket injection
|
||||
❌ "func()" — parentheses
|
||||
❌ "func name" — space injection
|
||||
❌ "func-name" — special chars
|
||||
❌ "a.b.c.d" — too deep (>3 segments)
|
||||
❌ "123func" — starts with number
|
||||
```
|
||||
|
||||
## iframe Bridge Security
|
||||
|
||||
### PluginPage.svelte ↔ iframe Communication
|
||||
|
||||
1. **Source verification**: Messages are only accepted from the specific `iframeEl.contentWindow` (not just any `source: 'calendar-plugin'`)
|
||||
2. **Type checking**: `msg.source`, `msg.action` must be strings
|
||||
3. **sandbox attribute**: iframe uses `sandbox="allow-scripts allow-same-origin"` (no forms, no popups)
|
||||
4. **srcdoc**: iframe content is injected as `srcdoc` (same origin, no external network)
|
||||
|
||||
### Message Protocol
|
||||
|
||||
All messages must have:
|
||||
```js
|
||||
{ source: 'calendar-plugin', action: 'ready' | 'get-events' | 'create-event' | ... }
|
||||
```
|
||||
|
||||
Messages from iframe to Svelte are validated:
|
||||
- `e.source === iframeEl.contentWindow` (origin check)
|
||||
- `msg.source === 'calendar-plugin'` (source check)
|
||||
- `typeof msg.action === 'string'` (type check)
|
||||
|
||||
## Plugin Lifecycle Security
|
||||
|
||||
### State Separation
|
||||
|
||||
| State | Description | Data Persistence |
|
||||
|-------|-------------|-----------------|
|
||||
| **Discovered** | Plugin found on disk | None |
|
||||
| **Installed** | `on_install` hook ran, tables created | DB tables exist |
|
||||
| **Enabled** | Plugin is active, Lua VM running | Full access |
|
||||
| **Active** | Runtime is initialized | Full access |
|
||||
| **Disabled** | Plugin deactivated, data preserved | DB tables exist |
|
||||
| **Uninstalled** | `on_uninstall` hook ran, tables dropped | Data cleaned |
|
||||
|
||||
### Idempotency
|
||||
|
||||
- `Install` on already-installed plugin → error (no duplicate config entries)
|
||||
- `Uninstall` on non-installed plugin → error
|
||||
- `Enable` on non-installed plugin → error
|
||||
- `Disable` on already-disabled plugin → no-op (error, but harmless)
|
||||
- `ReloadPlugins` → stops schedulers, calls shutdown hooks, closes VMs, re-discovers
|
||||
|
||||
### ReloadPlugins Cleanup Order
|
||||
|
||||
1. `StopSchedulers()` — stops all background tasks, sets `scheduler = nil`
|
||||
2. `CallShutdownHooks()` — calls `on_shutdown` for all active plugins
|
||||
3. `CloseRuntimes()` — closes all Lua VMs, sets `vm = nil`
|
||||
4. `Discover()` — re-scans plugins directory
|
||||
5. `SyncConfig()` — applies installed/enabled state from config
|
||||
6. `InitRuntimes()` — creates new VMs and schedulers
|
||||
7. `CallInitHooks()` — calls `on_init` for all active plugins
|
||||
8. `StartSchedulers()` — starts background tasks
|
||||
|
||||
## Network Access
|
||||
|
||||
Plugins have **no direct network access**. All HTTP/network operations must go through the `verstak.http.*` API (registered by the core), which:
|
||||
|
||||
- Uses Go's `net/http` (not Lua socket)
|
||||
- Can be rate-limited by the core
|
||||
- Goes through the proxy if configured
|
||||
|
||||
## Filesystem Access
|
||||
|
||||
Plugins have **no direct filesystem access**. All file operations must go through:
|
||||
|
||||
- `verstak.db.*` — SQLite queries (plugin's own DB only)
|
||||
- `verstak.node.*` — Node CRUD (via core services)
|
||||
- `verstak.files.*` — File operations (via core services)
|
||||
|
||||
The `io` library is not opened. `os.remove`, `os.rename` are blocked.
|
||||
|
||||
## Database Isolation
|
||||
|
||||
Each plugin gets its own SQLite database at `.verstak/plugins/<name>/data/`. Plugins cannot access:
|
||||
- The main Verstak database
|
||||
- Other plugins' databases
|
||||
- System databases
|
||||
|
||||
## Calendar Plugin (Reference)
|
||||
|
||||
The Calendar plugin in `contrib/plugins/calendar/` is a **reference implementation**, not a core feature:
|
||||
|
||||
- It is **not** loaded by default
|
||||
- It requires explicit `Install` + `Enable`
|
||||
- Its tables are created only during `on_install`
|
||||
- It can be fully `Uninstall`ed without affecting core functionality
|
||||
- It does not add calendar UI to any core views
|
||||
- It demonstrates the full plugin API: `verstak.db.*`, `verstak.config.*`, `verstak.state.*`, `verstak.node.*`, `verstak.activity.*`, `verstak.schedule.*`, `verstak.http.*`
|
||||
|
||||
## Security Tests
|
||||
|
||||
All security properties are verified by tests in `internal/core/plugins/security_test.go`:
|
||||
|
||||
- `TestLuaVM_Sandbox_Comprehensive` — 12 dangerous APIs blocked
|
||||
- `TestLuaVM_Sandbox_SafeLibs` — 9 safe libraries available
|
||||
- `TestLuaVM_Sandbox_GlobalsRemoved` — 11 dangerous globals nil
|
||||
- `TestLuaVM_Sandbox_OsRestricted` — 6 os.* functions blocked, 5 safe ones available
|
||||
- `TestLuaVM_Sandbox_IoBlocked` — io library not opened
|
||||
- `TestLuaVM_Sandbox_DebugBlocked` — debug library not opened
|
||||
- `TestLuaVM_Sandbox_CoroutineBlocked` — coroutine library not opened
|
||||
- `TestLuaVM_Sandbox_Package*Blocked` — 9 package.* fields blocked
|
||||
- `TestLuaVM_Sandbox_PrintIsRedirected` — print goes to Go log
|
||||
- `TestLuaVM_DoStringTimeout` — infinite loops killed by timeout
|
||||
- `TestLuaVM_Isolation` — plugins cannot see each other's globals
|
||||
- `TestLuaVM_ClosedVM` — operations on closed VM return errors
|
||||
- `TestLuaVM_DoString_InjectionAttempt` — dangerous APIs return nil
|
||||
- `TestPluginManager_InstallIdempotent` — no duplicate config entries
|
||||
- `TestPluginManager_DisablePreservesData` — Disable keeps data
|
||||
- `TestPluginManager_UninstallRemovesFromConfig` — Uninstall cleans config
|
||||
- `TestPluginManager_ReloadPlugins_Cleanup` — full cleanup on reload
|
||||
- `TestCallPluginFunction_Validation` — funcName validation (5 valid, 10 invalid cases)
|
||||
|
|
@ -35,10 +35,15 @@
|
|||
}
|
||||
}
|
||||
|
||||
// Handle messages from iframe
|
||||
// Handle messages from iframe — only accept from our own iframeEl
|
||||
function handleIframeMessage(e) {
|
||||
// Verify the message comes from our iframe (srcdoc = same origin)
|
||||
if (!iframeEl || !iframeEl.contentWindow || e.source !== iframeEl.contentWindow) return
|
||||
|
||||
const msg = e.data
|
||||
if (!msg || !msg.source || msg.source !== 'calendar-plugin') return
|
||||
if (!msg || typeof msg !== 'object') return
|
||||
if (!msg.source || msg.source !== 'calendar-plugin') return
|
||||
if (!msg.action || typeof msg.action !== 'string') return
|
||||
|
||||
switch (msg.action) {
|
||||
case 'ready':
|
||||
|
|
|
|||
|
|
@ -255,6 +255,7 @@ func (m *Manager) StopSchedulers() {
|
|||
p := &m.plugins[i]
|
||||
if p.scheduler != nil {
|
||||
p.scheduler.Stop()
|
||||
p.scheduler = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -426,13 +427,22 @@ func (m *Manager) Install(name string) error {
|
|||
return fmt.Errorf("on_install: %w", err)
|
||||
}
|
||||
|
||||
// Mark installed in config
|
||||
// Mark installed in config (idempotent — avoid duplicates)
|
||||
p.Installed = true
|
||||
appCfg, _ := config.LoadAppConfig()
|
||||
if appCfg == nil {
|
||||
appCfg = config.DefaultAppConfig()
|
||||
}
|
||||
already := false
|
||||
for _, n := range appCfg.InstalledPlugins {
|
||||
if n == name {
|
||||
already = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !already {
|
||||
appCfg.InstalledPlugins = append(appCfg.InstalledPlugins, name)
|
||||
}
|
||||
if err := config.SaveAppConfig(appCfg); err != nil {
|
||||
return fmt.Errorf("save config: %w", err)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -72,10 +72,19 @@ func NewLuaVM(p *Plugin) (*LuaVM, error) {
|
|||
}
|
||||
|
||||
// Disable dangerous functions
|
||||
for _, name := range []string{"dofile", "loadfile", "require", "module", "rawequal", "rawget", "rawset", "rawlen", "setfenv", "getfenv"} {
|
||||
for _, name := range []string{"dofile", "loadfile", "require", "module", "rawequal", "rawget", "rawset", "rawlen", "setfenv", "getfenv", "load"} {
|
||||
L.SetGlobal(name, lua.LNil)
|
||||
}
|
||||
|
||||
// Restrict package table: remove dangerous fields
|
||||
if pkgTbl := L.GetGlobal("package"); pkgTbl != lua.LNil {
|
||||
if tbl, ok := pkgTbl.(*lua.LTable); ok {
|
||||
for _, name := range []string{"loadlib", "seeall", "preload", "loaders", "loaded", "path", "cpath", "config", "searchpath"} {
|
||||
tbl.RawSetString(name, lua.LNil)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Restrict os.* to safe subset
|
||||
osSafe := map[string]bool{
|
||||
"clock": true, "date": true, "difftime": true, "time": true,
|
||||
|
|
|
|||
|
|
@ -77,8 +77,11 @@ func (s *Scheduler) Stop() {
|
|||
t.stopped = true
|
||||
}
|
||||
}
|
||||
tasks := s.tasks
|
||||
s.tasks = nil
|
||||
s.mu.Unlock()
|
||||
s.wg.Wait()
|
||||
_ = tasks // keep reference until wg done
|
||||
}
|
||||
|
||||
func (s *Scheduler) runTask(t *Task) {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,966 @@
|
|||
package plugins
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"verstak/internal/core/config"
|
||||
|
||||
lua "github.com/yuin/gopher-lua"
|
||||
)
|
||||
|
||||
// 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_]*$`)
|
||||
|
||||
// TestLuaVM_Sandbox_Comprehensive verifies that dangerous Lua APIs are blocked.
|
||||
func TestLuaVM_Sandbox_Comprehensive(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
pluginDir := filepath.Join(dir, "sandbox2")
|
||||
if err := os.MkdirAll(pluginDir, 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
mainLua := `
|
||||
function test_dofile()
|
||||
if dofile == nil then return false end
|
||||
local ok = pcall(function() dofile("/etc/passwd") end)
|
||||
return ok
|
||||
end
|
||||
function test_loadfile()
|
||||
if loadfile == nil then return false end
|
||||
local ok = pcall(function() loadfile("/etc/passwd") end)
|
||||
return ok
|
||||
end
|
||||
function test_require()
|
||||
if require == nil then return false end
|
||||
local ok = pcall(function() require("os") end)
|
||||
return ok
|
||||
end
|
||||
function test_os_execute()
|
||||
if os == nil or os.execute == nil then return false end
|
||||
local ok = pcall(function() os.execute("id") end)
|
||||
return ok
|
||||
end
|
||||
function test_os_remove()
|
||||
if os == nil or os.remove == nil then return false end
|
||||
local ok = pcall(function() os.remove("/tmp/test") end)
|
||||
return ok
|
||||
end
|
||||
function test_os_rename()
|
||||
if os == nil or os.rename == nil then return false end
|
||||
local ok = pcall(function() os.rename("/tmp/a", "/tmp/b") end)
|
||||
return ok
|
||||
end
|
||||
function test_os_getenv()
|
||||
if os == nil or os.getenv == nil then return false end
|
||||
local ok = pcall(function() return os.getenv("PATH") end)
|
||||
return ok
|
||||
end
|
||||
function test_io_open()
|
||||
if io == nil or io.open == nil then return false end
|
||||
local ok = pcall(function() io.open("/etc/passwd", "r") end)
|
||||
return ok
|
||||
end
|
||||
function test_load()
|
||||
if load == nil then return false end
|
||||
local ok = pcall(function() load("return 1") end)
|
||||
return ok
|
||||
end
|
||||
function test_package_loadlib()
|
||||
if package == nil or package.loadlib == nil then return false end
|
||||
local ok = pcall(function() package.loadlib("libc.so.6", "malloc") end)
|
||||
return ok
|
||||
end
|
||||
function test_rawget()
|
||||
if rawget == nil then return false end
|
||||
return true
|
||||
end
|
||||
function test_setfenv()
|
||||
if setfenv == nil then return false end
|
||||
return true
|
||||
end
|
||||
`
|
||||
if err := os.WriteFile(filepath.Join(pluginDir, "main.lua"), []byte(mainLua), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
p := &Plugin{
|
||||
Meta: Meta{Name: "sandbox2"},
|
||||
Dir: pluginDir,
|
||||
Active: true,
|
||||
}
|
||||
|
||||
vm, err := NewLuaVM(p)
|
||||
if err != nil {
|
||||
t.Fatalf("NewLuaVM: %v", err)
|
||||
}
|
||||
defer vm.Close()
|
||||
|
||||
if err := vm.LoadScript("main.lua"); err != nil {
|
||||
t.Fatalf("LoadScript: %v", err)
|
||||
}
|
||||
|
||||
dangerousTests := []struct {
|
||||
name string
|
||||
fnName string
|
||||
wantSafe bool
|
||||
}{
|
||||
{"dofile", "test_dofile", true},
|
||||
{"loadfile", "test_loadfile", true},
|
||||
{"require", "test_require", true},
|
||||
{"os.execute", "test_os_execute", true},
|
||||
{"os.remove", "test_os_remove", true},
|
||||
{"os.rename", "test_os_rename", true},
|
||||
{"os.getenv", "test_os_getenv", true},
|
||||
{"io.open", "test_io_open", true},
|
||||
{"load", "test_load", true},
|
||||
{"package.loadlib", "test_package_loadlib", true},
|
||||
{"rawget", "test_rawget", true},
|
||||
{"setfenv", "test_setfenv", true},
|
||||
}
|
||||
|
||||
for _, tt := range dangerousTests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result, err := vm.CallHookWithResult(tt.fnName)
|
||||
if err != nil {
|
||||
t.Fatalf("CallHook(%s): %v", tt.fnName, err)
|
||||
}
|
||||
succeeded := result == lua.LTrue
|
||||
if tt.wantSafe && succeeded {
|
||||
t.Errorf("%s: dangerous API was NOT blocked (returned true)", tt.name)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestLuaVM_Sandbox_SafeLibs verifies that safe Lua libraries still work.
|
||||
func TestLuaVM_Sandbox_SafeLibs(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
pluginDir := filepath.Join(dir, "safelibs")
|
||||
if err := os.MkdirAll(pluginDir, 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
mainLua := `
|
||||
function test_string()
|
||||
local s = string.format("hello %s", "world")
|
||||
return s == "hello world"
|
||||
end
|
||||
function test_table()
|
||||
local t = {1, 2, 3}
|
||||
return #t == 3
|
||||
end
|
||||
function test_math()
|
||||
return math.max(1, 2, 3) == 3
|
||||
end
|
||||
function test_type()
|
||||
return type(42) == "number" and type("hi") == "string"
|
||||
end
|
||||
function test_pcall()
|
||||
local ok, err = pcall(function() error("test") end)
|
||||
return not ok and err ~= nil
|
||||
end
|
||||
function test_pairs()
|
||||
local t = {a=1, b=2}
|
||||
local count = 0
|
||||
for k, v in pairs(t) do count = count + 1 end
|
||||
return count == 2
|
||||
end
|
||||
function test_os_clock()
|
||||
if os == nil or os.clock == nil then return false end
|
||||
local c = os.clock()
|
||||
return c > 0
|
||||
end
|
||||
function test_os_date()
|
||||
if os == nil or os.date == nil then return false end
|
||||
local d = os.date("%Y")
|
||||
return #d == 4
|
||||
end
|
||||
function test_os_time()
|
||||
if os == nil or os.time == nil then return false end
|
||||
local t = os.time()
|
||||
return t > 0
|
||||
end
|
||||
`
|
||||
if err := os.WriteFile(filepath.Join(pluginDir, "main.lua"), []byte(mainLua), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
p := &Plugin{
|
||||
Meta: Meta{Name: "safelibs"},
|
||||
Dir: pluginDir,
|
||||
Active: true,
|
||||
}
|
||||
|
||||
vm, err := NewLuaVM(p)
|
||||
if err != nil {
|
||||
t.Fatalf("NewLuaVM: %v", err)
|
||||
}
|
||||
defer vm.Close()
|
||||
|
||||
if err := vm.LoadScript("main.lua"); err != nil {
|
||||
t.Fatalf("LoadScript: %v", err)
|
||||
}
|
||||
|
||||
safeTests := []struct {
|
||||
name string
|
||||
fnName string
|
||||
}{
|
||||
{"string", "test_string"},
|
||||
{"table", "test_table"},
|
||||
{"math", "test_math"},
|
||||
{"type", "test_type"},
|
||||
{"pcall", "test_pcall"},
|
||||
{"pairs", "test_pairs"},
|
||||
{"os.clock", "test_os_clock"},
|
||||
{"os.date", "test_os_date"},
|
||||
{"os.time", "test_os_time"},
|
||||
}
|
||||
|
||||
for _, tt := range safeTests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result, err := vm.CallHookWithResult(tt.fnName)
|
||||
if err != nil {
|
||||
t.Fatalf("CallHook(%s): %v", tt.fnName, err)
|
||||
}
|
||||
if result != lua.LTrue {
|
||||
t.Errorf("%s: expected true, got %v", tt.name, result)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestLuaVM_Sandbox_GlobalsRemoved verifies that dangerous globals are nil.
|
||||
func TestLuaVM_Sandbox_GlobalsRemoved(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
pluginDir := filepath.Join(dir, "globals")
|
||||
os.MkdirAll(pluginDir, 0755)
|
||||
|
||||
p := &Plugin{Meta: Meta{Name: "globals"}, Dir: pluginDir, Active: true}
|
||||
vm, err := NewLuaVM(p)
|
||||
if err != nil {
|
||||
t.Fatalf("NewLuaVM: %v", err)
|
||||
}
|
||||
defer vm.Close()
|
||||
|
||||
dangerous := []string{"dofile", "loadfile", "require", "module", "rawequal", "rawget", "rawset", "rawlen", "setfenv", "getfenv", "load"}
|
||||
for _, name := range dangerous {
|
||||
val := vm.L.GetGlobal(name)
|
||||
if val != lua.LNil {
|
||||
t.Errorf("global %q should be nil, got %v", name, val)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestLuaVM_Sandbox_OsRestricted verifies that os.* is restricted to safe subset.
|
||||
func TestLuaVM_Sandbox_OsRestricted(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
pluginDir := filepath.Join(dir, "osrestrict")
|
||||
os.MkdirAll(pluginDir, 0755)
|
||||
|
||||
p := &Plugin{Meta: Meta{Name: "osrestrict"}, Dir: pluginDir, Active: true}
|
||||
vm, err := NewLuaVM(p)
|
||||
if err != nil {
|
||||
t.Fatalf("NewLuaVM: %v", err)
|
||||
}
|
||||
defer vm.Close()
|
||||
|
||||
blocked := []string{"execute", "exit", "remove", "rename", "setlocale", "getenv"}
|
||||
for _, name := range blocked {
|
||||
osTbl := vm.L.GetGlobal("os")
|
||||
if osTbl == lua.LNil {
|
||||
t.Fatal("os table is nil")
|
||||
}
|
||||
tbl := osTbl.(*lua.LTable)
|
||||
val := tbl.RawGetString(name)
|
||||
if val != lua.LNil {
|
||||
t.Errorf("os.%s should be nil, got %v", name, val)
|
||||
}
|
||||
}
|
||||
|
||||
safe := []string{"clock", "date", "difftime", "time", "tmpname"}
|
||||
for _, name := range safe {
|
||||
osTbl := vm.L.GetGlobal("os")
|
||||
tbl := osTbl.(*lua.LTable)
|
||||
val := tbl.RawGetString(name)
|
||||
if val == lua.LNil {
|
||||
t.Errorf("os.%s should be available, got nil", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestLuaVM_Sandbox_IoBlocked verifies that io.* is completely blocked.
|
||||
func TestLuaVM_Sandbox_IoBlocked(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
pluginDir := filepath.Join(dir, "ioblock")
|
||||
os.MkdirAll(pluginDir, 0755)
|
||||
|
||||
p := &Plugin{Meta: Meta{Name: "ioblock"}, Dir: pluginDir, Active: true}
|
||||
vm, err := NewLuaVM(p)
|
||||
if err != nil {
|
||||
t.Fatalf("NewLuaVM: %v", err)
|
||||
}
|
||||
defer vm.Close()
|
||||
|
||||
ioTbl := vm.L.GetGlobal("io")
|
||||
if ioTbl != lua.LNil {
|
||||
t.Errorf("io should be nil (not opened), got %v", ioTbl)
|
||||
}
|
||||
}
|
||||
|
||||
// TestLuaVM_Sandbox_DebugBlocked verifies that debug library is not available.
|
||||
func TestLuaVM_Sandbox_DebugBlocked(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
pluginDir := filepath.Join(dir, "debugblock")
|
||||
os.MkdirAll(pluginDir, 0755)
|
||||
|
||||
p := &Plugin{Meta: Meta{Name: "debugblock"}, Dir: pluginDir, Active: true}
|
||||
vm, err := NewLuaVM(p)
|
||||
if err != nil {
|
||||
t.Fatalf("NewLuaVM: %v", err)
|
||||
}
|
||||
defer vm.Close()
|
||||
|
||||
debugVal := vm.L.GetGlobal("debug")
|
||||
if debugVal != lua.LNil {
|
||||
t.Errorf("debug should be nil (not opened), got %v", debugVal)
|
||||
}
|
||||
}
|
||||
|
||||
// TestLuaVM_Sandbox_CoroutineBlocked verifies coroutine library is not available.
|
||||
func TestLuaVM_Sandbox_CoroutineBlocked(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
pluginDir := filepath.Join(dir, "coroblock")
|
||||
os.MkdirAll(pluginDir, 0755)
|
||||
|
||||
p := &Plugin{Meta: Meta{Name: "coroblock"}, Dir: pluginDir, Active: true}
|
||||
vm, err := NewLuaVM(p)
|
||||
if err != nil {
|
||||
t.Fatalf("NewLuaVM: %v", err)
|
||||
}
|
||||
defer vm.Close()
|
||||
|
||||
coroVal := vm.L.GetGlobal("coroutine")
|
||||
if coroVal != lua.LNil {
|
||||
t.Errorf("coroutine should be nil (not opened), got %v", coroVal)
|
||||
}
|
||||
}
|
||||
|
||||
// TestLuaVM_Sandbox_PackageLoadlibBlocked verifies package.loadlib is blocked.
|
||||
func TestLuaVM_Sandbox_PackageLoadlibBlocked(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
pluginDir := filepath.Join(dir, "loadlib")
|
||||
os.MkdirAll(pluginDir, 0755)
|
||||
|
||||
p := &Plugin{Meta: Meta{Name: "loadlib"}, Dir: pluginDir, Active: true}
|
||||
vm, err := NewLuaVM(p)
|
||||
if err != nil {
|
||||
t.Fatalf("NewLuaVM: %v", err)
|
||||
}
|
||||
defer vm.Close()
|
||||
|
||||
pkgTbl := vm.L.GetGlobal("package")
|
||||
if pkgTbl == lua.LNil {
|
||||
t.Fatal("package table is nil")
|
||||
}
|
||||
tbl := pkgTbl.(*lua.LTable)
|
||||
loadlib := tbl.RawGetString("loadlib")
|
||||
if loadlib != lua.LNil {
|
||||
t.Errorf("package.loadlib should be nil, got %v", loadlib)
|
||||
}
|
||||
}
|
||||
|
||||
// TestLuaVM_Sandbox_PackageSeeallBlocked verifies package.seeall is blocked.
|
||||
func TestLuaVM_Sandbox_PackageSeeallBlocked(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
pluginDir := filepath.Join(dir, "seeall")
|
||||
os.MkdirAll(pluginDir, 0755)
|
||||
|
||||
p := &Plugin{Meta: Meta{Name: "seeall"}, Dir: pluginDir, Active: true}
|
||||
vm, err := NewLuaVM(p)
|
||||
if err != nil {
|
||||
t.Fatalf("NewLuaVM: %v", err)
|
||||
}
|
||||
defer vm.Close()
|
||||
|
||||
pkgTbl := vm.L.GetGlobal("package")
|
||||
if pkgTbl == lua.LNil {
|
||||
t.Fatal("package table is nil")
|
||||
}
|
||||
tbl := pkgTbl.(*lua.LTable)
|
||||
seeall := tbl.RawGetString("seeall")
|
||||
if seeall != lua.LNil {
|
||||
t.Errorf("package.seeall should be nil, got %v", seeall)
|
||||
}
|
||||
}
|
||||
|
||||
// TestLuaVM_Sandbox_PackagePreloadBlocked verifies package.preload is blocked.
|
||||
func TestLuaVM_Sandbox_PackagePreloadBlocked(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
pluginDir := filepath.Join(dir, "preload")
|
||||
os.MkdirAll(pluginDir, 0755)
|
||||
|
||||
p := &Plugin{Meta: Meta{Name: "preload"}, Dir: pluginDir, Active: true}
|
||||
vm, err := NewLuaVM(p)
|
||||
if err != nil {
|
||||
t.Fatalf("NewLuaVM: %v", err)
|
||||
}
|
||||
defer vm.Close()
|
||||
|
||||
pkgTbl := vm.L.GetGlobal("package")
|
||||
if pkgTbl == lua.LNil {
|
||||
t.Fatal("package table is nil")
|
||||
}
|
||||
tbl := pkgTbl.(*lua.LTable)
|
||||
preload := tbl.RawGetString("preload")
|
||||
if preload != lua.LNil {
|
||||
t.Errorf("package.preload should be nil, got %v", preload)
|
||||
}
|
||||
}
|
||||
|
||||
// TestLuaVM_Sandbox_PackageLoadersBlocked verifies package.loaders is blocked.
|
||||
func TestLuaVM_Sandbox_PackageLoadersBlocked(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
pluginDir := filepath.Join(dir, "loaders")
|
||||
os.MkdirAll(pluginDir, 0755)
|
||||
|
||||
p := &Plugin{Meta: Meta{Name: "loaders"}, Dir: pluginDir, Active: true}
|
||||
vm, err := NewLuaVM(p)
|
||||
if err != nil {
|
||||
t.Fatalf("NewLuaVM: %v", err)
|
||||
}
|
||||
defer vm.Close()
|
||||
|
||||
pkgTbl := vm.L.GetGlobal("package")
|
||||
if pkgTbl == lua.LNil {
|
||||
t.Fatal("package table is nil")
|
||||
}
|
||||
tbl := pkgTbl.(*lua.LTable)
|
||||
loaders := tbl.RawGetString("loaders")
|
||||
if loaders != lua.LNil {
|
||||
t.Errorf("package.loaders should be nil, got %v", loaders)
|
||||
}
|
||||
}
|
||||
|
||||
// TestLuaVM_Sandbox_PackagePathBlocked verifies package.path is blocked.
|
||||
func TestLuaVM_Sandbox_PackagePathBlocked(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
pluginDir := filepath.Join(dir, "pkgpath")
|
||||
os.MkdirAll(pluginDir, 0755)
|
||||
|
||||
p := &Plugin{Meta: Meta{Name: "pkgpath"}, Dir: pluginDir, Active: true}
|
||||
vm, err := NewLuaVM(p)
|
||||
if err != nil {
|
||||
t.Fatalf("NewLuaVM: %v", err)
|
||||
}
|
||||
defer vm.Close()
|
||||
|
||||
pkgTbl := vm.L.GetGlobal("package")
|
||||
if pkgTbl == lua.LNil {
|
||||
t.Fatal("package table is nil")
|
||||
}
|
||||
tbl := pkgTbl.(*lua.LTable)
|
||||
path := tbl.RawGetString("path")
|
||||
if path != lua.LNil {
|
||||
t.Errorf("package.path should be nil, got %v", path)
|
||||
}
|
||||
}
|
||||
|
||||
// TestLuaVM_Sandbox_PackageCpathBlocked verifies package.cpath is blocked.
|
||||
func TestLuaVM_Sandbox_PackageCpathBlocked(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
pluginDir := filepath.Join(dir, "pkgcpath")
|
||||
os.MkdirAll(pluginDir, 0755)
|
||||
|
||||
p := &Plugin{Meta: Meta{Name: "pkgcpath"}, Dir: pluginDir, Active: true}
|
||||
vm, err := NewLuaVM(p)
|
||||
if err != nil {
|
||||
t.Fatalf("NewLuaVM: %v", err)
|
||||
}
|
||||
defer vm.Close()
|
||||
|
||||
pkgTbl := vm.L.GetGlobal("package")
|
||||
if pkgTbl == lua.LNil {
|
||||
t.Fatal("package table is nil")
|
||||
}
|
||||
tbl := pkgTbl.(*lua.LTable)
|
||||
cpath := tbl.RawGetString("cpath")
|
||||
if cpath != lua.LNil {
|
||||
t.Errorf("package.cpath should be nil, got %v", cpath)
|
||||
}
|
||||
}
|
||||
|
||||
// TestLuaVM_Sandbox_PackageLoadedBlocked verifies package.loaded is blocked.
|
||||
func TestLuaVM_Sandbox_PackageLoadedBlocked(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
pluginDir := filepath.Join(dir, "pkgloaded")
|
||||
os.MkdirAll(pluginDir, 0755)
|
||||
|
||||
p := &Plugin{Meta: Meta{Name: "pkgloaded"}, Dir: pluginDir, Active: true}
|
||||
vm, err := NewLuaVM(p)
|
||||
if err != nil {
|
||||
t.Fatalf("NewLuaVM: %v", err)
|
||||
}
|
||||
defer vm.Close()
|
||||
|
||||
pkgTbl := vm.L.GetGlobal("package")
|
||||
if pkgTbl == lua.LNil {
|
||||
t.Fatal("package table is nil")
|
||||
}
|
||||
tbl := pkgTbl.(*lua.LTable)
|
||||
loaded := tbl.RawGetString("loaded")
|
||||
if loaded != lua.LNil {
|
||||
t.Errorf("package.loaded should be nil, got %v", loaded)
|
||||
}
|
||||
}
|
||||
|
||||
// TestLuaVM_Sandbox_PackageConfigBlocked verifies package.config is blocked.
|
||||
func TestLuaVM_Sandbox_PackageConfigBlocked(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
pluginDir := filepath.Join(dir, "pkgconfig")
|
||||
os.MkdirAll(pluginDir, 0755)
|
||||
|
||||
p := &Plugin{Meta: Meta{Name: "pkgconfig"}, Dir: pluginDir, Active: true}
|
||||
vm, err := NewLuaVM(p)
|
||||
if err != nil {
|
||||
t.Fatalf("NewLuaVM: %v", err)
|
||||
}
|
||||
defer vm.Close()
|
||||
|
||||
pkgTbl := vm.L.GetGlobal("package")
|
||||
if pkgTbl == lua.LNil {
|
||||
t.Fatal("package table is nil")
|
||||
}
|
||||
tbl := pkgTbl.(*lua.LTable)
|
||||
config := tbl.RawGetString("config")
|
||||
if config != lua.LNil {
|
||||
t.Errorf("package.config should be nil, got %v", config)
|
||||
}
|
||||
}
|
||||
|
||||
// TestLuaVM_Sandbox_PackageSearchpathBlocked verifies package.searchpath is blocked.
|
||||
func TestLuaVM_Sandbox_PackageSearchpathBlocked(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
pluginDir := filepath.Join(dir, "searchpath")
|
||||
os.MkdirAll(pluginDir, 0755)
|
||||
|
||||
p := &Plugin{Meta: Meta{Name: "searchpath"}, Dir: pluginDir, Active: true}
|
||||
vm, err := NewLuaVM(p)
|
||||
if err != nil {
|
||||
t.Fatalf("NewLuaVM: %v", err)
|
||||
}
|
||||
defer vm.Close()
|
||||
|
||||
pkgTbl := vm.L.GetGlobal("package")
|
||||
if pkgTbl == lua.LNil {
|
||||
t.Fatal("package table is nil")
|
||||
}
|
||||
tbl := pkgTbl.(*lua.LTable)
|
||||
searchpath := tbl.RawGetString("searchpath")
|
||||
if searchpath != lua.LNil {
|
||||
t.Errorf("package.searchpath should be nil, got %v", searchpath)
|
||||
}
|
||||
}
|
||||
|
||||
// TestLuaVM_Sandbox_PrintIsRedirected verifies print is the Go-redirected version.
|
||||
func TestLuaVM_Sandbox_PrintIsRedirected(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
pluginDir := filepath.Join(dir, "printredir")
|
||||
os.MkdirAll(pluginDir, 0755)
|
||||
|
||||
p := &Plugin{Meta: Meta{Name: "printredir"}, Dir: pluginDir, Active: true}
|
||||
vm, err := NewLuaVM(p)
|
||||
if err != nil {
|
||||
t.Fatalf("NewLuaVM: %v", err)
|
||||
}
|
||||
defer vm.Close()
|
||||
|
||||
printVal := vm.L.GetGlobal("print")
|
||||
if printVal == lua.LNil {
|
||||
t.Fatal("print should be available")
|
||||
}
|
||||
if _, ok := printVal.(*lua.LFunction); !ok {
|
||||
t.Errorf("print should be a function, got %T", printVal)
|
||||
}
|
||||
}
|
||||
|
||||
// TestLuaVM_DoStringTimeout verifies that infinite loops are killed by timeout.
|
||||
func TestLuaVM_DoStringTimeout(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
pluginDir := filepath.Join(dir, "timeout")
|
||||
os.MkdirAll(pluginDir, 0755)
|
||||
os.WriteFile(filepath.Join(pluginDir, "main.lua"), []byte(`
|
||||
function infinite_loop()
|
||||
while true do end
|
||||
end
|
||||
`), 0644)
|
||||
|
||||
p := &Plugin{Meta: Meta{Name: "timeout"}, Dir: pluginDir, Active: true}
|
||||
vm, err := NewLuaVM(p)
|
||||
if err != nil {
|
||||
t.Fatalf("NewLuaVM: %v", err)
|
||||
}
|
||||
defer vm.Close()
|
||||
|
||||
if err := vm.LoadScript("main.lua"); err != nil {
|
||||
t.Fatalf("LoadScript: %v", err)
|
||||
}
|
||||
|
||||
vm.callTimeout = 1 * time.Second
|
||||
|
||||
done := make(chan error, 1)
|
||||
go func() {
|
||||
_, err := vm.CallHookWithResult("infinite_loop")
|
||||
done <- err
|
||||
}()
|
||||
|
||||
select {
|
||||
case err := <-done:
|
||||
if err == nil {
|
||||
t.Error("expected timeout error for infinite loop")
|
||||
}
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("test hung — timeout didn't work")
|
||||
}
|
||||
}
|
||||
|
||||
// TestLuaVM_Isolation verifies that two plugins have separate Lua states.
|
||||
func TestLuaVM_Isolation(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
|
||||
pluginDirA := filepath.Join(dir, "plugina")
|
||||
os.MkdirAll(pluginDirA, 0755)
|
||||
os.WriteFile(filepath.Join(pluginDirA, "main.lua"), []byte(`
|
||||
function set_global()
|
||||
plugin_a_secret = "leaked"
|
||||
end
|
||||
function get_global()
|
||||
return plugin_a_secret
|
||||
end
|
||||
`), 0644)
|
||||
|
||||
pluginDirB := filepath.Join(dir, "pluginb")
|
||||
os.MkdirAll(pluginDirB, 0755)
|
||||
os.WriteFile(filepath.Join(pluginDirB, "main.lua"), []byte(`
|
||||
function check_isolation()
|
||||
return plugin_a_secret == nil
|
||||
end
|
||||
`), 0644)
|
||||
|
||||
pA := &Plugin{Meta: Meta{Name: "plugina"}, Dir: pluginDirA, Active: true}
|
||||
pB := &Plugin{Meta: Meta{Name: "pluginb"}, Dir: pluginDirB, Active: true}
|
||||
|
||||
vmA, _ := NewLuaVM(pA)
|
||||
defer vmA.Close()
|
||||
vmB, _ := NewLuaVM(pB)
|
||||
defer vmB.Close()
|
||||
|
||||
vmA.LoadScript("main.lua")
|
||||
vmB.LoadScript("main.lua")
|
||||
|
||||
vmA.CallHook("set_global")
|
||||
|
||||
result, err := vmB.CallHookWithResult("check_isolation")
|
||||
if err != nil {
|
||||
t.Fatalf("CallHook: %v", err)
|
||||
}
|
||||
if result != lua.LTrue {
|
||||
t.Error("plugin B could see plugin A's global — isolation broken")
|
||||
}
|
||||
}
|
||||
|
||||
// TestLuaVM_ClosedVM verifies that operations on a closed VM return errors.
|
||||
func TestLuaVM_ClosedVM(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
pluginDir := filepath.Join(dir, "closed")
|
||||
os.MkdirAll(pluginDir, 0755)
|
||||
os.WriteFile(filepath.Join(pluginDir, "main.lua"), []byte(`function test() return true end`), 0644)
|
||||
|
||||
p := &Plugin{Meta: Meta{Name: "closed"}, Dir: pluginDir, Active: true}
|
||||
vm, _ := NewLuaVM(p)
|
||||
vm.LoadScript("main.lua")
|
||||
vm.Close()
|
||||
|
||||
_, err := vm.DoString("return 1")
|
||||
if err == nil {
|
||||
t.Error("expected error from DoString on closed VM")
|
||||
}
|
||||
}
|
||||
|
||||
// TestLuaVM_DoString_InjectionAttempt verifies that dangerous APIs are blocked.
|
||||
func TestLuaVM_DoString_InjectionAttempt(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
pluginDir := filepath.Join(dir, "inject")
|
||||
os.MkdirAll(pluginDir, 0755)
|
||||
os.WriteFile(filepath.Join(pluginDir, "main.lua"), []byte(`
|
||||
function innocent()
|
||||
return "innocent"
|
||||
end
|
||||
`), 0644)
|
||||
|
||||
p := &Plugin{Meta: Meta{Name: "inject"}, Dir: pluginDir, Active: true}
|
||||
vm, _ := NewLuaVM(p)
|
||||
defer vm.Close()
|
||||
vm.LoadScript("main.lua")
|
||||
|
||||
// dofile is blocked — attempting to call it should error
|
||||
_, err := vm.DoString("return dofile('/etc/passwd')")
|
||||
if err == nil {
|
||||
t.Error("dofile should be blocked in sandbox")
|
||||
}
|
||||
|
||||
// package.loadlib is nil — accessing it should return "nil"
|
||||
result, err := vm.DoString("return package.loadlib")
|
||||
if err != nil {
|
||||
t.Fatalf("DoString(package.loadlib): %v", err)
|
||||
}
|
||||
if result != "nil" {
|
||||
t.Errorf("package.loadlib should be nil, got %q", result)
|
||||
}
|
||||
|
||||
// package.loaders is nil
|
||||
result, err = vm.DoString("return package.loaders")
|
||||
if err != nil {
|
||||
t.Fatalf("DoString(package.loaders): %v", err)
|
||||
}
|
||||
if result != "nil" {
|
||||
t.Errorf("package.loaders should be nil, got %q", result)
|
||||
}
|
||||
|
||||
// os.execute is nil
|
||||
result, err = vm.DoString("return os.execute")
|
||||
if err != nil {
|
||||
t.Fatalf("DoString(os.execute): %v", err)
|
||||
}
|
||||
if result != "nil" {
|
||||
t.Errorf("os.execute should be nil, got %q", result)
|
||||
}
|
||||
|
||||
// io is nil (not opened)
|
||||
result, err = vm.DoString("return io")
|
||||
if err != nil {
|
||||
t.Fatalf("DoString(io): %v", err)
|
||||
}
|
||||
if result != "nil" {
|
||||
t.Errorf("io should be nil, got %q", result)
|
||||
}
|
||||
|
||||
// load is nil (blocked global)
|
||||
result, err = vm.DoString("return load")
|
||||
if err != nil {
|
||||
t.Fatalf("DoString(load): %v", err)
|
||||
}
|
||||
if result != "nil" {
|
||||
t.Errorf("load should be nil, got %q", result)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPluginManager_InstallIdempotent verifies that installing an already-installed
|
||||
// plugin does not duplicate entries in config.
|
||||
func TestPluginManager_InstallIdempotent(t *testing.T) {
|
||||
root := setupPluginDir(t, map[string]*fsDir{
|
||||
"idem": {
|
||||
files: map[string][]byte{
|
||||
"plugin.json": []byte(`{"name": "idem", "version": "1.0", "hooks": {"on_install": "on_install"}}`),
|
||||
"main.lua": []byte(`function on_install() end`),
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
mgr := NewManager(root)
|
||||
mgr.Discover()
|
||||
|
||||
if err := mgr.Install("idem"); err != nil {
|
||||
t.Fatalf("first install: %v", err)
|
||||
}
|
||||
|
||||
if err := mgr.Install("idem"); err == nil {
|
||||
t.Fatal("expected error on double install, got nil")
|
||||
}
|
||||
|
||||
appCfg, _ := config.LoadAppConfig()
|
||||
count := 0
|
||||
for _, n := range appCfg.InstalledPlugins {
|
||||
if n == "idem" {
|
||||
count++
|
||||
}
|
||||
}
|
||||
if count != 1 {
|
||||
t.Errorf("expected 1 entry in InstalledPlugins, got %d: %v", count, appCfg.InstalledPlugins)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPluginManager_DisablePreservesData verifies that Disable does not
|
||||
// remove the plugin from the installed list.
|
||||
func TestPluginManager_DisablePreservesData(t *testing.T) {
|
||||
root := setupPluginDir(t, map[string]*fsDir{
|
||||
"keepdata": {
|
||||
files: map[string][]byte{
|
||||
"plugin.json": []byte(`{"name": "keepdata", "version": "1.0", "hooks": {"on_install": "on_install"}}`),
|
||||
"main.lua": []byte(`function on_install() end`),
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
mgr := NewManager(root)
|
||||
mgr.Discover()
|
||||
mgr.Install("keepdata")
|
||||
mgr.Enable("keepdata")
|
||||
|
||||
mgr.Disable("keepdata")
|
||||
|
||||
p := mgr.Plugins()[0]
|
||||
if !p.Installed {
|
||||
t.Error("Installed should remain true after Disable")
|
||||
}
|
||||
if p.Active {
|
||||
t.Error("Active should be false after Disable")
|
||||
}
|
||||
}
|
||||
|
||||
// TestPluginManager_UninstallRemovesFromConfig verifies that Uninstall
|
||||
// removes the plugin from both installed and enabled lists.
|
||||
func TestPluginManager_UninstallRemovesFromConfig(t *testing.T) {
|
||||
root := setupPluginDir(t, map[string]*fsDir{
|
||||
"gone": {
|
||||
files: map[string][]byte{
|
||||
"plugin.json": []byte(`{"name": "gone", "version": "1.0", "hooks": {"on_install": "on_install", "on_uninstall": "on_uninstall"}}`),
|
||||
"main.lua": []byte(`function on_install() end function on_uninstall() end`),
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
mgr := NewManager(root)
|
||||
mgr.Discover()
|
||||
mgr.Install("gone")
|
||||
mgr.Enable("gone")
|
||||
|
||||
if err := mgr.Uninstall("gone"); err != nil {
|
||||
t.Fatalf("uninstall: %v", err)
|
||||
}
|
||||
|
||||
appCfg, _ := config.LoadAppConfig()
|
||||
for _, n := range appCfg.InstalledPlugins {
|
||||
if n == "gone" {
|
||||
t.Error("plugin should be removed from InstalledPlugins after Uninstall")
|
||||
}
|
||||
}
|
||||
for _, n := range appCfg.EnabledPlugins {
|
||||
if n == "gone" {
|
||||
t.Error("plugin should be removed from EnabledPlugins after Uninstall")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestPluginManager_ReloadPlugins_Cleanup verifies that ReloadPlugins
|
||||
// properly stops schedulers and closes old VMs.
|
||||
func TestPluginManager_ReloadPlugins_Cleanup(t *testing.T) {
|
||||
root := setupPluginDir(t, map[string]*fsDir{
|
||||
"reloadme": {
|
||||
files: map[string][]byte{
|
||||
"plugin.json": []byte(`{"name": "reloadme", "version": "1.0", "hooks": {"on_install": "on_install", "on_init": "on_init", "on_shutdown": "on_shutdown"}}`),
|
||||
"main.lua": []byte(`function on_install() end function on_init() end function on_shutdown() end`),
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
mgr := NewManager(root)
|
||||
mgr.Discover()
|
||||
mgr.Install("reloadme")
|
||||
mgr.Enable("reloadme")
|
||||
mgr.InitRuntimes()
|
||||
|
||||
if len(mgr.Active()) != 1 {
|
||||
t.Fatalf("expected 1 active plugin, got %d", len(mgr.Active()))
|
||||
}
|
||||
|
||||
// Simulate what ReloadPlugins does
|
||||
mgr.StopSchedulers()
|
||||
mgr.CallShutdownHooks()
|
||||
mgr.CloseRuntimes()
|
||||
|
||||
for _, p := range mgr.Plugins() {
|
||||
if p.vm != nil {
|
||||
t.Error("VM should be nil after CloseRuntimes")
|
||||
}
|
||||
if p.scheduler != nil {
|
||||
t.Error("scheduler should be nil after StopSchedulers")
|
||||
}
|
||||
}
|
||||
|
||||
mgr.InitRuntimes()
|
||||
mgr.CallInitHooks()
|
||||
mgr.StartSchedulers()
|
||||
|
||||
if len(mgr.Active()) != 1 {
|
||||
t.Errorf("expected 1 active plugin after re-init, got %d", len(mgr.Active()))
|
||||
}
|
||||
|
||||
mgr.StopSchedulers()
|
||||
mgr.CloseRuntimes()
|
||||
}
|
||||
|
||||
// TestCallPluginFunction_Validation tests that the funcName validation
|
||||
// rejects dangerous patterns.
|
||||
func TestCallPluginFunction_Validation(t *testing.T) {
|
||||
validCases := []string{
|
||||
"create_event",
|
||||
"calendar.create_event",
|
||||
"a.b.c",
|
||||
"_private",
|
||||
"func123",
|
||||
}
|
||||
invalidCases := []string{
|
||||
"",
|
||||
"func;os.execute('id')",
|
||||
"func..name",
|
||||
"func[name]",
|
||||
"func()",
|
||||
"func name",
|
||||
"func-name",
|
||||
"func@name",
|
||||
"a.b.c.d",
|
||||
"123func",
|
||||
}
|
||||
|
||||
for _, tc := range validCases {
|
||||
t.Run("valid_"+tc, func(t *testing.T) {
|
||||
segments := strings.Split(tc, ".")
|
||||
if len(segments) > 3 {
|
||||
t.Fatalf("should be valid: %s", tc)
|
||||
}
|
||||
for _, seg := range segments {
|
||||
if !validLuaIdent.MatchString(seg) {
|
||||
t.Fatalf("should be valid: %s (segment %s)", tc, seg)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
for _, tc := range invalidCases {
|
||||
t.Run("invalid_"+tc, func(t *testing.T) {
|
||||
if tc == "" {
|
||||
return
|
||||
}
|
||||
segments := strings.Split(tc, ".")
|
||||
valid := true
|
||||
if len(segments) > 3 {
|
||||
valid = false
|
||||
} else {
|
||||
for _, seg := range segments {
|
||||
if !validLuaIdent.MatchString(seg) {
|
||||
valid = false
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if valid {
|
||||
t.Errorf("should be invalid: %s", tc)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue