Skip to content

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.



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.

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.

CategoryBlocked modules
OS / filesystemos, sys, shutil, pathlib, io, tempfile, tokenize, linecache
Processsubprocess, asyncio.subprocess, _subprocess
Networksocket, _socket, http.*, urllib.*, webbrowser
Dynamic loadingimportlib.*, pkgutil, imp, runpy, compileall, codeop, code
FFI / native codectypes, _ctypes, cffi
Serializationpickle, _pickle, marshal, shelve
Concurrencymultiprocessing, threading
System primitivessignal, resource, pwd, grp, pty, tty, termios, sysconfig, site
Introspectioninspect, gc
Otheratexit, 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 any fromlist entries against the blocked list.

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 gc object walks via object references obtained after load
  • Third-party modules that were already cached in sys.modules before the plugin loaded

The confused-deputy guard prevents a plugin from exercising authority that the calling user does not themselves hold.

Every authenticated HTTP request to a plugin route binds two contexts:

  1. Plugin runtime context - the plugin’s own declared permissions (from plugin.yaml).
  2. 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 → allowed

The capability-to-core-permission map:

SDK capability (plugin.yaml code)Equivalent core permission
devices.readdevice:read
devices.writedevice:write
alerts.readalert:read
alerts.writealert:write

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.

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 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.


Every SDK operation is scoped to a single organization_id. Isolation is application-layer only - not enforced at the database level.

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 - filters Device → Site.organization_id.
  • DeviceSDK.get - verifies ownership before returning; returns None for devices not in the org.
  • DeviceSDK.register_device - validates that site_id belongs to the org before upserting.
  • AlertSDK.list - filters Alert.organization_id.
  • AlertSDK.create - writes organization_id to 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.

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.


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.

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 Unauthorized

A 410 Gone response from a plugin route means one of:

  • The plugin is globally disabled (InstalledPlugin.is_active = false).
  • The plugin has a PluginOrganizationState row with is_enabled = false for the caller’s org.
  • The plugin runtime failed to start for this org.
  • The plugin has no active runtimes anywhere.

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

Every request to a public route must include:

HeaderPurpose
X-FreeSDN-Plugin-OrgOrganization ID the secret belongs to
X-FreeSDN-Plugin-TimestampUnix timestamp (must be within 300 s of server time)
X-FreeSDN-Plugin-Nonce16-128 character random string
X-FreeSDN-Plugin-Signaturesha256= + HMAC-SHA256(secret, canonical_message)

The canonical message is the newline-joined concatenation of: timestamp, nonce, METHOD, path, query, org_id, sha256(body).

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.

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.


Plugin installation is restricted to super_admin. There is no delegated install - an org_admin cannot install a plugin even for their own org.

Before any code is loaded:

  1. Compressed size checked against the 50 MB cap.
  2. Each member path is checked for zip-slip traversal (no .., no absolute paths, no symlinks that escape the extraction root).
  3. Uncompressed size tracked against the 200 MB cap.

If a plugin declares python_dependencies in plugin.yaml, the runtime enforces:

  • Every entry must match name==version exactly. Loose specifiers (>=, ~=, !=, extras syntax) are rejected.
  • A requirements.txt containing --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) to sys.path, so it never shadows core packages.

This feature is off by default. Set PLUGIN_ALLOW_RUNTIME_PYTHON_DEPS=true to enable it.

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.

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:

LimitValue
Devices registered per plugin1,000
Automation triggers50
Automation actions50
AI tools20
Outbound HTTP timeout60 s
Outbound HTTP response body10 MB
AI tool result payload256 KB
Settings blob32 KiB
plugin.yaml description2,000 chars
python_dependencies entries50
ZIP compressed50 MB
ZIP uncompressed200 MB

This summary is intentionally direct. Use it to calibrate your risk acceptance before installing a plugin.

  • 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: PluginHTTPClient DNS-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 open and os at 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 AsyncSession objects 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.environ at runtime despite os being in the blocked list, because the block is load-time only.
  • Network (runtime): The hygiene layer blocks socket and http.* 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:

  1. Read the source code - specifically plugin.py (the entry point) and any modules it imports.
  2. Run freesdn-sdk check <path> - catches obvious blocked-import violations via static AST scan.
  3. Review python_dependencies - verify every pinned package is a known, well-maintained library. Check each against its own upstream security record.
  4. Verify permissions - confirm the plugin only declares capabilities it actually needs.
  5. Verify event_subscriptions - confirm the plugin only subscribes to event patterns it actually needs.
  6. Verify public_routes - if any are declared, confirm you need to expose an unauthenticated webhook endpoint for this plugin.
  7. Check on_install and lifecycle hooks - these receive raw AsyncSession objects and run once with full DB access.
  8. Test in a non-production org first - use per-org disable to confine the plugin before enabling it platform-wide.