verstak/docs/plugins-security.md

204 lines
8.3 KiB
Markdown

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