Plugin Security Model
Plugin Security Model
The plugin system is designed so that a malicious or buggy plugin cannot compromise CMS stability, access data it should not, or affect other plugins. This page documents every layer of the security model.
Lua Sandbox
Source: internal/plugin/sandbox.go
Each plugin runs in a restricted Lua 5.1 environment. Before any plugin code executes, the sandbox removes dangerous functions and modules.
Removed Modules
| Module | Reason |
|---|---|
io |
No filesystem read/write access |
os |
No process execution, environment access, or system calls |
package |
No arbitrary module loading from the filesystem |
debug |
No VM introspection, stack manipulation, or upvalue access |
Removed Global Functions
| Function | Reason |
|---|---|
dofile |
No dynamic code loading from files |
loadfile |
No dynamic code loading from files |
load |
No dynamic code loading from strings |
rawget |
Prevents bypassing frozen module metatables |
rawset |
Prevents bypassing frozen module metatables |
rawequal |
Prevents bypassing metatable-based protections |
rawlen |
Prevents bypassing metatable-based protections |
What Remains
Only safe standard library functions are available. See the Lua API reference for the complete list. In summary: base (type checks, iteration, error handling, metatable get/set), string (manipulation, pattern matching), table (insert, remove, sort, concat), math (arithmetic, random). The coroutine library is additionally available for plugins that declare screens or interfaces in their manifest, enabling the TUI coroutine bridge for plugin-driven terminal UI.
Frozen Module Protection
All injected API modules (db, http, hooks, log, tui) are frozen read-only via a metatable proxy:
- Writes raise an error.
db.query = nilordb.custom = function() endfails with an error. getmetatablereturns"protected". Prevents retrieving and modifying the proxy metatable.rawget/rawsetare removed. The standard Lua bypass for metatables is unavailable.
This prevents plugins from monkey-patching API modules to escalate privileges or intercept other plugins' calls.
Source: internal/plugin/sandbox.go (FreezeModule)
Database Namespace Isolation
All plugin database tables are prefixed with plugin_<name>_. A plugin named bookmarks that defines a table links gets plugin_bookmarks_links in the database.
Enforcement
Table name validation happens at the Go level before any SQL is executed. The db.* API functions:
- Prepend the plugin prefix to the table name provided in Lua.
- Validate that the resulting table name starts with the correct prefix.
- Reject any attempt to reference a table outside the plugin's namespace.
What Plugins Cannot Do
- Access CMS tables. Queries against
content_data,users,media, or any other CMS table are rejected. - Access other plugins' tables. A plugin named
foocannot queryplugin_bar_tasks. - Create cross-namespace foreign keys. Foreign key
ref_tablevalues must match the plugin's own prefix.
Auto-Injected Columns
Every plugin table receives three columns automatically: id (TEXT PRIMARY KEY, ULID), created_at (TEXT, RFC3339 UTC), updated_at (TEXT, RFC3339 UTC). Plugins cannot redefine these columns.
Schema Drift Detection
After db.define_table() creates a table, the system inspects actual database columns and compares them against the Lua definition. Mismatches are logged as warnings and surfaced in the GET /api/v1/admin/plugins/{name} response. Drift is advisory only -- the system does not auto-migrate tables.
Response Header Blocking
Plugin HTTP responses are filtered before being sent to the client. The following headers are silently dropped if set by a plugin:
| Blocked Header | Attack Vector |
|---|---|
access-control-* |
CORS policy override -- a plugin could allow arbitrary origins |
set-cookie |
Session fixation or hijacking via injected cookies |
transfer-encoding |
Response smuggling via chunked encoding manipulation |
content-length |
Response smuggling via length mismatch |
cache-control |
Cache poisoning via forced caching of dynamic responses |
host |
Request smuggling via host header manipulation |
connection |
Request smuggling via connection lifecycle manipulation |
Two security headers are set automatically on all plugin responses and cannot be overridden:
X-Content-Type-Options: nosniffX-Frame-Options: DENY
Operation Budgets
Each VM checkout (HTTP handler or hook execution) has a finite operation budget. Every db.* call decrements the budget by one. This prevents a single handler from monopolizing database connections with unbounded queries.
| Context | Default Budget | Config Key |
|---|---|---|
| HTTP request handler | 1000 | plugin_max_ops |
| After-hook | 100 | plugin_hook_max_ops |
| Before-hook | 0 (all blocked) | -- |
Exceeding the budget raises a Lua error (ErrOpLimitExceeded). The handler can catch this with pcall but cannot perform further database operations.
Why Before-Hooks Block db.*
Before-hooks run synchronously inside the CMS database transaction. Plugin database operations use a separate connection pool. On SQLite (single-writer), a plugin db.* call would attempt to acquire a write lock while the CMS transaction already holds one, causing a deadlock. On MySQL/PostgreSQL, it would create long-held locks and risk timeouts. The system blocks db.* entirely in before-hooks rather than allowing partial access that could fail unpredictably.
Use after-hooks for any database work. After-hooks run after the CMS transaction commits, so there is no lock contention.
Circuit Breakers
Two independent circuit breaker systems protect the CMS from misbehaving plugins.
Plugin-Level Circuit Breaker
Source: internal/plugin/recovery.go
Tracks consecutive HTTP handler failures and manager operation errors. Does not count hook failures.
| State | Behavior |
|---|---|
| Closed | Normal operation. Requests are processed. |
| Open | All requests return 503 immediately. No code executes. |
| Half-Open | One probe request is allowed. Success closes the breaker. Failure re-opens it. |
Transitions:
- Closed to Open:
plugin_max_failuresconsecutive failures (default 5). - Open to Half-Open:
plugin_reset_intervalelapses (default 60s). - Half-Open to Closed: Probe request succeeds.
- Half-Open to Open: Probe request fails.
Manual reset: modulacms plugin enable <name> resets the circuit breaker and triggers a fresh load.
Hook-Level Circuit Breaker
Source: internal/plugin/hook_engine.go
Each (plugin, event, table) combination has its own circuit breaker. This is more granular than the plugin-level breaker -- a failing before_create hook on content_data does not affect after_update hooks on the same table.
After plugin_hook_max_consecutive_aborts consecutive errors (default 10), that specific hook is disabled. It remains disabled until the plugin is reloaded or re-enabled.
Hook failures never feed into the plugin-level circuit breaker. A plugin can have all its hooks disabled while its HTTP endpoints continue to function normally.
Rate Limiting
Source: internal/plugin/http_bridge.go
Per-IP token bucket rate limiter protects plugin endpoints from abuse.
Design
- Each unique client IP gets its own token bucket.
- Bucket capacity equals the configured rate (default 100 tokens).
- Tokens refill at the configured rate per second.
- When a request arrives and no token is available, the bridge returns HTTP 429 (Too Many Requests).
Cleanup
Every 5 minutes, the rate limiter scans all entries and removes those not seen in the last 10 minutes. This prevents unbounded memory growth from many unique IPs.
Trusted Proxies
When plugin_trusted_proxies is configured with CIDR ranges, requests arriving from those IPs have their X-Forwarded-For header parsed to extract the real client IP. Without trusted proxy configuration, the direct connection IP is used, which behind a load balancer would be the proxy's IP -- causing all clients to share a single rate limit bucket.
Route and Hook Approval
All routes and hooks start unapproved. This is a security gate: new plugin code does not execute until an admin explicitly approves it.
- Unapproved routes return 404 as if they do not exist.
- Unapproved hooks are silently skipped.
Version-Change Revocation
When a plugin's version field in plugin_info changes, all route and hook approvals are automatically revoked. The admin must re-approve after reviewing the updated code. This prevents a plugin from changing its behavior (e.g., adding data exfiltration to an approved route) without going through the approval gate again.
See approval workflow for the full approval process.
Hot Reload Security
Source: internal/plugin/watcher.go
The hot reload watcher has several safety limits to prevent abuse:
| Limit | Value | Purpose |
|---|---|---|
| Max .lua files per plugin | 100 | Prevents DoS via creating thousands of files that must be checksummed |
| Max total size per checksum | 10 MB | Prevents DoS via large files consuming CPU during SHA-256 hashing |
| Reload cooldown | 10s per plugin | Prevents reload storms from rapid file changes |
| Max consecutive slow reloads | 3 (>10s each) | Pauses watcher for plugins with systemic initialization issues |
| Debounce delay | 1s | Waits for file writes to settle before triggering reload |
Blue-Green Safety
Reload creates a new plugin instance alongside the old one. The new instance goes through full initialization (VM pool, init.lua, routes, hooks, on_init()). Only after the new instance is fully running does the old one drain and shut down.
If the new instance fails at any point during initialization, the old instance remains active. No traffic is dropped during the transition.
Eviction
After 3 consecutive slow reloads (each taking more than 10 seconds), the watcher pauses monitoring for that plugin. This prevents a plugin with a fundamentally broken initialization from consuming resources on every poll cycle. The plugin continues to run with its last successful version.
Trusted Proxy Configuration
Accurate client IP identification is critical for rate limiting and req.client_ip in plugin handlers. Behind a reverse proxy or load balancer, the direct connection IP is the proxy's IP, not the client's.
Configure trusted proxies with CIDR notation:
{
"plugin_trusted_proxies": ["10.0.0.0/8", "172.16.0.0/12"]
}
When a request arrives from a trusted proxy IP, the rightmost non-trusted IP in the X-Forwarded-For chain is used as the client IP. If no trusted proxies are configured, the direct connection IP is used.
Do not add 0.0.0.0/0 or other overly broad ranges. This would allow any client to spoof their IP via the X-Forwarded-For header, bypassing rate limiting entirely.