# 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//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)