Plugin Security Model
Plugin security in FreeSDN is based on a cooperative trusted-author model, not a process sandbox. This page is the authoritative reference for what the runtime actually enforces at each trust boundary, what it does not, and what that means for you as an operator deciding whether to install a plugin.
Read this page before installing any plugin - especially third-party plugins.
The single most important fact
Section titled “The single most important fact”What the load-time hygiene layer does
Section titled “What the load-time hygiene layer does”Even though the hygiene layer is not a security sandbox, it is not nothing. It blocks the most common accidental or opportunistic abuse paths during the module-load phase.
Blocked module imports (load-time only)
Section titled “Blocked module imports (load-time only)”When the plugin module is exec’d, a MetaPathFinder raises ImportError for every module in the following categories. The blocked set is removed from sys.modules for the duration of the exec and then restored.
| Category | Blocked modules |
|---|---|
| OS / filesystem | os, sys, shutil, pathlib, io, tempfile, tokenize, linecache |
| Process | subprocess, asyncio.subprocess, _subprocess |
| Network | socket, _socket, http.*, urllib.*, webbrowser |
| Dynamic loading | importlib.*, pkgutil, imp, runpy, compileall, codeop, code |
| FFI / native code | ctypes, _ctypes, cffi |
| Serialization | pickle, _pickle, marshal, shelve |
| Concurrency | multiprocessing, threading |
| System primitives | signal, resource, pwd, grp, pty, tty, termios, sysconfig, site |
| Introspection | inspect, gc |
| Other | atexit, builtins |
Restricted builtins
Section titled “Restricted builtins”restrict_plugin_builtins replaces the plugin module’s __builtins__ with an allowlist. The following are removed or replaced:
exec,eval,compile,open- removed entirely.__import__- replaced with a checking wrapper that validates both the top-level module name and anyfromlistentries against the blocked list.
What is NOT blocked
Section titled “What is NOT blocked”The hygiene layer has documented escape routes that it acknowledges but cannot close without subprocess isolation:
traceback(not in the blocked list)().__class__.__base__.__subclasses__()- standard Python introspection at runtime- Runtime
gcobject walks via object references obtained after load - Third-party modules that were already cached in
sys.modulesbefore the plugin loaded
Confused-deputy guard (CAN-015)
Section titled “Confused-deputy guard (CAN-015)”The confused-deputy guard prevents a plugin from exercising authority that the calling user does not themselves hold.
How it works
Section titled “How it works”Every authenticated HTTP request to a plugin route binds two contexts:
- Plugin runtime context - the plugin’s own declared permissions (from
plugin.yaml). - Caller context - the authenticated user’s role and permissions.
When the plugin calls an SDK method (e.g. self.ctx.devices.list()), _require_permission checks two conditions in sequence:
1. Is this capability declared in plugin.yaml permissions[]? → No → PermissionError (capability not declared) → Yes → continue
2. Is there a bound caller (an authenticated user hit this route)? → No → plugin acts with its own declared authority (see note below) → Yes → does the caller hold the equivalent core permission? → No → PermissionError (confused-deputy blocked) → Yes → allowedThe capability-to-core-permission map:
SDK capability (plugin.yaml code) | Equivalent core permission |
|---|---|
devices.read | device:read |
devices.write | device:write |
alerts.read | alert:read |
alerts.write | alert:write |
Practical example
Section titled “Practical example”A plugin declares devices.write. A viewer-role user (who holds device:read but not device:write) hits a plugin REST endpoint that calls self.ctx.devices.register_device(...). The guard sees that the caller does not hold device:write and raises PermissionError - the plugin cannot escalate above what the caller is allowed to do, regardless of what the plugin itself declared.
When CAN-015 does not apply
Section titled “When CAN-015 does not apply”CAN-015 only intersects authority when there is a bound authenticated caller. On the following paths there is no caller, so the plugin acts with its own declared authority:
- Public/HMAC routes - unauthenticated inbound webhooks verified by
X-FreeSDN-Plugin-Signature. - Automation rule execution - triggered by the automation engine, not a user request.
- AI tool execution - called by the AI service loop. Note: AI tools have a separate mandatory permission gate (see below).
- Scheduled/background work - Celery tasks or event handlers initiated by event bus subscriptions.
This is intentional design: automated workflows need to act on behalf of the org, not a specific user. The tradeoff is that plugin code on these paths is trusted at its declared authority without a caller intersection.
AI tool permission gate (PS-11)
Section titled “AI tool permission gate (PS-11)”AI tools registered by a plugin use a separate fail-closed gate because they run with the calling user’s raw user and db objects, not the org-scoped SDK context.
If you call register_ai_tool without providing a permission argument, the runtime coerces the tool’s permission to the sentinel value plugin:undeclared_ai_tool. No role in the system grants that sentinel - the tool becomes accessible only to super_admin in practice.
Additionally, the AI service has a defense-in-depth guard at execution time: if a plugin_* tool somehow reaches the registry with tool.permission still unset (i.e. bypassing the bridge), it returns “Permission denied” immediately. In practice the bridge always stores the non-falsy effective_permission value, so this branch is never reached for tools registered through the normal SDK path - the effective outcome is the one described in the callout below: the tool is blocked for every role below super_admin, which holds an implicit grant for the sentinel.
Org-scoping
Section titled “Org-scoping”Every SDK operation is scoped to a single organization_id. Isolation is application-layer only - not enforced at the database level.
Per-org runtime instances
Section titled “Per-org runtime instances”The loader maintains separate plugin runtime instances per (plugin_id, organization_id) pair. When on_start(organization_id, db) is called, a PluginContext is constructed with that org’s ID baked in. Every SDK method queries with organization_id as an explicit filter:
DeviceSDK.list- filtersDevice → Site.organization_id.DeviceSDK.get- verifies ownership before returning; returnsNonefor devices not in the org.DeviceSDK.register_device- validates thatsite_idbelongs to the org before upserting.AlertSDK.list- filtersAlert.organization_id.AlertSDK.create- writesorganization_idto the new alert row.PluginSettingsSDK- scoped to(plugin_id, organization_id, key).
Event bus subscriptions are similarly filtered: the handler registered by bind_event_subscriptions checks event.organization_id before passing the event to on_event.
Plugin event namespace isolation
Section titled “Plugin event namespace isolation”Plugins cannot emit events into the core event namespace. All calls to ctx.events.emit("my_event", payload) are force-prefixed to plugin.{plugin_id}.my_event by the SDK. There is no way for a plugin to publish alert.created or any other core event type through the SDK.
Route guard and the 410 behaviour
Section titled “Route guard and the 410 behaviour”Plugin routes are mounted into FastAPI once at load time and remain registered in the router until the process restarts. The route guard (_build_route_guard) runs as a Depends() on every request.
Guard decision tree
Section titled “Guard decision tree”Request arrives at /api/v1/{plugin_prefix}/...│├─ Authenticated user with org│ ├─ Active runtime for this (plugin, org)? Yes → bind ctx → proceed│ └─ No active runtime → 410 Gone│├─ Authenticated user with no org context → 403 Forbidden│└─ Unauthenticated ├─ Path + method match a public_routes entry? Yes → verify HMAC → bind org runtime → proceed ├─ HMAC invalid → 401 Unauthorized └─ No matching public route → 401 UnauthorizedWhat 410 means
Section titled “What 410 means”A 410 Gone response from a plugin route means one of:
- The plugin is globally disabled (
InstalledPlugin.is_active = false). - The plugin has a
PluginOrganizationStaterow withis_enabled = falsefor the caller’s org. - The plugin runtime failed to start for this org.
- The plugin has no active runtimes anywhere.
Public routes and HMAC authentication
Section titled “Public routes and HMAC authentication”Plugins can expose unauthenticated inbound webhook endpoints by declaring them in plugin.yaml. These are intended for receiving callbacks from external systems (e.g. a third-party monitoring platform posting events).
Required headers
Section titled “Required headers”Every request to a public route must include:
| Header | Purpose |
|---|---|
X-FreeSDN-Plugin-Org | Organization ID the secret belongs to |
X-FreeSDN-Plugin-Timestamp | Unix timestamp (must be within 300 s of server time) |
X-FreeSDN-Plugin-Nonce | 16-128 character random string |
X-FreeSDN-Plugin-Signature | sha256= + HMAC-SHA256(secret, canonical_message) |
The canonical message is the newline-joined concatenation of: timestamp, nonce, METHOD, path, query, org_id, sha256(body).
Replay protection
Section titled “Replay protection”Nonces are stored in Valkey with a 300-second TTL keyed plugin:public:nonce:{plugin}:{org}:{nonce}. A repeated nonce within the window returns 401. If Valkey is unavailable, the verification returns 503 - it does not fail open.
Secret management
Section titled “Secret management”The HMAC secret is generated per (plugin, org) pair by POST /api/v1/plugins/{id}/public-auth/rotate-secret. The plaintext secret is returned exactly once and then discarded - only the Fernet-encrypted form is stored. Rotation requires org_admin.
Supply-chain controls
Section titled “Supply-chain controls”Install gate
Section titled “Install gate”Plugin installation is restricted to super_admin. There is no delegated install - an org_admin cannot install a plugin even for their own org.
ZIP validation
Section titled “ZIP validation”Before any code is loaded:
- Compressed size checked against the 50 MB cap.
- Each member path is checked for zip-slip traversal (no
.., no absolute paths, no symlinks that escape the extraction root). - Uncompressed size tracked against the 200 MB cap.
Python dependency pinning
Section titled “Python dependency pinning”If a plugin declares python_dependencies in plugin.yaml, the runtime enforces:
- Every entry must match
name==versionexactly. Loose specifiers (>=,~=,!=, extras syntax) are rejected. - A
requirements.txtcontaining--hash=sha256:annotations is required. - Installation uses
pip install --require-hashes --no-deps --no-cache-dir --only-binary :all:, blocking sdist code execution and transitive upgrades. - Deps install into a per-plugin
.venv; the site-packages directory is appended (not prepended) tosys.path, so it never shadows core packages.
This feature is off by default. Set PLUGIN_ALLOW_RUNTIME_PYTHON_DEPS=true to enable it.
Marketplace catalog signing
Section titled “Marketplace catalog signing”POST /api/v1/marketplace/plugins/sync verifies an Ed25519 detached signature over the canonical catalog JSON before importing any records. With neither MARKETPLACE_PUBLISHER_PUBLIC_KEY nor MARKETPLACE_ALLOW_UNSIGNED=true set, the sync endpoint returns 403 - it does not silently accept an unsigned catalog.
Marketplace installs verify the downloaded ZIP against the catalog’s checksum_sha256 before passing it to the loader.
Audit trail
Section titled “Audit trail”Every lifecycle event produces an audit log entry tagged plugin and supply-chain:
- Install, upgrade, uninstall
- Enable / disable (global and per-org)
- HMAC secret rotation
Hard limits that apply regardless of declared permissions
Section titled “Hard limits that apply regardless of declared permissions”These limits are enforced by the runtime and cannot be overridden by a plugin:
| Limit | Value |
|---|---|
| Devices registered per plugin | 1,000 |
| Automation triggers | 50 |
| Automation actions | 50 |
| AI tools | 20 |
| Outbound HTTP timeout | 60 s |
| Outbound HTTP response body | 10 MB |
| AI tool result payload | 256 KB |
| Settings blob | 32 KiB |
plugin.yaml description | 2,000 chars |
python_dependencies entries | 50 |
| ZIP compressed | 50 MB |
| ZIP uncompressed | 200 MB |
What is and is not isolated
Section titled “What is and is not isolated”This summary is intentionally direct. Use it to calibrate your risk acceptance before installing a plugin.
Isolated (enforced by the runtime)
Section titled “Isolated (enforced by the runtime)”- Org data: SDK methods filter all queries to the plugin’s bound
organization_id. - Event namespace: Plugin events are force-prefixed
plugin.{id}.*; core namespaces cannot be spoofed. - Device registration quota: 1,000-device cap enforced per plugin at the DB upsert layer.
- Outbound HTTP targets:
PluginHTTPClientDNS-pins addresses, rejects RFC 1918 / loopback / link-local / CGNAT, and follows zero redirects. Blocked-header stripping prevents proxy injection. - Caller authority intersection (CAN-015): On authenticated routes, the plugin cannot exercise a permission the calling user does not hold.
- AI tool permissions (PS-11): Undeclared tool permissions fail closed - not silently open.
- Action handler namespace: A plugin cannot register an action key owned by another plugin.
- Settings namespace: Settings are scoped to
(plugin_id, organization_id, key). - HMAC nonce replay prevention: Valkey-backed, fail-closed if Valkey is unavailable.
Not isolated (operating in shared process space)
Section titled “Not isolated (operating in shared process space)”- Python interpreter: The plugin shares the backend’s Python interpreter. A determined actor can reach any in-process object.
- Memory: There is no memory-address isolation between the plugin and core application objects.
- File system: The hygiene layer blocks
openandosat load time, but a plugin that defers these calls to runtime or reaches them via__subclasses__()can access the host filesystem. - Database (raw SQL): The plugin receives
AsyncSessionobjects through lifecycle hooks (on_install,on_start,on_upgrade,on_uninstall). Nothing prevents a plugin from executing arbitrary SQL through those sessions - the SDK org-scoping guards only apply to SDK methods. - Environment variables: Not blocked. A plugin running in the backend process can read
os.environat runtime despiteosbeing in the blocked list, because the block is load-time only. - Network (runtime): The hygiene layer blocks
socketandhttp.*at load time. A plugin that accesses these through an already-imported third-party dependency or through the object graph bypasses this block.
Operator checklist before installing a plugin
Section titled “Operator checklist before installing a plugin”Complete this checklist before installing any plugin that did not originate from the signed marketplace:
- Read the source code - specifically
plugin.py(the entry point) and any modules it imports. - Run
freesdn-sdk check <path>- catches obvious blocked-import violations via static AST scan. - Review
python_dependencies- verify every pinned package is a known, well-maintained library. Check each against its own upstream security record. - Verify
permissions- confirm the plugin only declares capabilities it actually needs. - Verify
event_subscriptions- confirm the plugin only subscribes to event patterns it actually needs. - Verify
public_routes- if any are declared, confirm you need to expose an unauthenticated webhook endpoint for this plugin. - Check
on_installand lifecycle hooks - these receive rawAsyncSessionobjects and run once with full DB access. - Test in a non-production org first - use per-org disable to confine the plugin before enabling it platform-wide.
Next steps
Section titled “Next steps”- Plugin overview - the two-tier extensibility model and what plugins can and cannot do.
- Manifest reference - every
plugin.yamlfield with validation rules. - SDK reference -
DeviceSDK,AlertSDK,EventSDK,PluginSettingsSDK, andPluginHTTPClientmethod signatures. - Automation and AI bridges - registering triggers, actions, and AI tools, including the PS-11 permission gate in detail.
- Packaging and publishing - building and submitting a plugin ZIP.
- Platform security model - the broader security architecture that plugin trust builds on top of.