Plugin Security Model
Plugin Security Model
How ModulaCMS prevents plugins from compromising CMS stability, accessing unauthorized data, or affecting other plugins.
Lua Sandbox
Each plugin runs in a restricted Lua 5.1 environment. Before any plugin code executes, ModulaCMS 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 |
Available Standard Library
Only safe standard library functions remain. 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).
Good to know: The
coroutinelibrary is additionally available for plugins that declarescreensorinterfacesin 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.
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.
What Plugins Cannot Access
- CMS tables. Queries against content, users, media, or any other CMS table are rejected.
- Other plugins' tables. A plugin named
foocannot queryplugin_bar_tasks. - 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 (ULID primary key), created_at (RFC3339 UTC), updated_at (RFC3339 UTC). Plugins cannot redefine these columns.
Schema Drift Detection
After db.define_table() creates a table, ModulaCMS compares the actual database columns against the Lua definition. Mismatches appear as warnings in the GET /api/v1/admin/plugins/{name} response. Drift is advisory only -- ModulaCMS does not auto-migrate tables.
Response Header Blocking
Plugin HTTP responses are filtered before reaching the client. The following headers are silently dropped if a plugin sets them:
| 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 plugin execution context has a finite operation budget. Every db.* call decrements the budget by one, preventing a single handler from monopolizing database connections.
| 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. The handler can catch this with pcall but cannot perform further database operations.
Good to know: Before-hooks block all
db.*calls because they run inside the CMS database transaction. A plugin database call from within a before-hook would deadlock on SQLite or risk long-held locks on other databases. Use after-hooks for database work.
Circuit Breakers
Two independent circuit breaker systems protect the CMS from misbehaving plugins.
Plugin-Level Circuit Breaker
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
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 until you reload or re-enable the plugin.
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
A per-IP token bucket rate limiter protects plugin endpoints from abuse.
- Each unique client IP gets its own token bucket.
- Bucket capacity equals the configured rate (default 100 tokens, refilling at 100/s).
- When no token is available, the request receives HTTP 429 (Too Many Requests).
- Inactive entries are cleaned up automatically to prevent memory growth.
Trusted Proxies
When plugin_trusted_proxies is configured with CIDR ranges, ModulaCMS parses the X-Forwarded-For header from those IPs to extract the real client IP. Without trusted proxy configuration, the direct connection IP is used -- behind a load balancer, this means all clients share a single rate limit bucket.
Route and Hook Approval
All routes and hooks start unapproved. Plugin code does not execute until an admin explicitly approves it.
- Unapproved routes return 404 as if they don't exist.
- Unapproved hooks are silently skipped.
When a plugin's version field changes, ModulaCMS automatically revokes all approvals. The admin must re-approve after reviewing the updated code. See approval workflow for the full process.
Hot Reload Security
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
ModulaCMS creates a new plugin instance alongside the old one during reload. Only after the new instance fully initializes does the old one shut down. If the new instance fails, the old one remains active and no traffic is dropped.
Eviction
After 3 consecutive slow reloads (each taking more than 10 seconds), the watcher pauses monitoring for that plugin. 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, ModulaCMS uses the rightmost non-trusted IP in the X-Forwarded-For chain 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.