Skip to content

Security model ​

What a plugin can and cannot reach, and where the walls are. Read this before handling untrusted input, secrets, or other plugins' data — it tells you which guarantees you can lean on and which are your responsibility.

The guiding principle is fail closed: anything not explicitly declared and allowed is rejected.

Capabilities are the gate ​

Every privileged IPC action maps to a capability string. The runtime checks each incoming call against your manifest permissions before dispatching it; an undeclared call is rejected with CAPABILITY_DENIED and never reaches your handler. The manifest is the complete, auditable list of what a plugin can do.

  • Scoped capabilities (data.sql:self, http.fetch:api.example.com) only grant the named scope. http.fetch to a host you didn't declare is denied.
  • Wildcards are constrained at manifest-validation time: data.read:* and events.subscribe:* are rejected — you must name the target.

Full grammar and the capability list: Permissions.

Per-plugin data isolation ​

Each plugin gets its own SQLite database. There is no shared database and no cross-plugin write path — ever.

  • Your data.sql:self operates only on your database.
  • Another plugin can read declared parts of your data only through data.read:<your-slug>.<table>, and only if you publish that table in public_schema. The read opens your database read-only and returns only the columns you listed.

public_schema is an allowlist, and internals are off-limits ​

A table is invisible to other plugins unless you put it in public_schema, and even then only its listed columns are readable. Two hard rules:

  • Reserved tables can't be exposed. Table names beginning with _ (the runtime's internals — _config for settings, _kv, _dlq) are rejected by manifest validation (RESERVED_PUBLIC_SCHEMA_TABLE). This is what stops a plugin from accidentally publishing its own settings — including secrets — to other plugins.
  • Only list what you mean to share. Columns outside the declared set are never returned, so omit anything sensitive.

Settings and secrets ​

Settings declared type: "secret" are redacted from the runtime's logs and diagnostics. They are not hidden from your own plugin — plugin.settings.get / getAll return the plaintext, because your plugin needs its own API keys to function.

So read "secret" as: sensitive, log-redacted, readable by this plugin, never exposed cross-plugin. Your responsibilities:

  • Don't echo secret values into your own console.log (that path isn't redacted) or into broadcasts/responses.
  • Don't store a secret in a column you publish via public_schema (and you can't publish _config anyway — see above).

Outbound HTTP (plugin.fetch) ​

plugin.fetch is a guarded egress, not raw network access:

  • Host allowlist — only hostnames you declared as http.fetch:<host>. The request URL's hostname must match.
  • Scheme lock — http: / https: only.
  • No redirects — responses are returned as-is (redirect: "manual"); a 30x doesn't silently follow to a new host.
  • Header hygiene — Host and Cookie are always stripped; Authorization is stripped on Central-targeted requests.
  • Bounds — 30 s timeout, 10 MB response cap.
  • Public destinations only — localhost, host.docker.internal and friends are rejected by name, and the hostname is resolved and classified before the request goes out. If any resolved address is non-public (RFC1918, loopback, link-local, CGNAT), the call fails with DESTINATION_BLOCKED. Empty resolution fails too. This is unconditional — there is no flag or opt-in.

Reaching a LAN or localhost service. plugin.fetch cannot do it, by design: a marketplace plugin must not be able to probe the owner's home network from an ad-hoc URL. The supported path is a reverse-proxy mount — declare it in proxy_mounts[] with an upstream_setting, and the owner supplies the address and approves it. Approved mounts do reach private addresses; the runtime pins the address class recorded at approval time and forces re-approval (PROXY_REAPPROVAL_REQUIRED) if it later drifts. So Home Assistant, a local game server, or any homelab upstream is a first-class use case — it just goes through owner-approved mounts rather than raw egress. See Reverse-proxy plugins.

When auditing a plugin you install, the two things to read are its declared http.fetch:<host> list and its proxy_mounts[].

File storage is jailed ​

plugin.files.* operates only inside your plugin's uploads/ directory:

  • Filenames are whitelisted ([a-zA-Z0-9_.-], ≤255 chars); ./.. and path separators are rejected, and the resolved path must stay inside uploads/.
  • Signed URLs (files.signUrl) are bound to a user id and time-limited (default 1 h, max 24 h). Mint them per read; don't hand out long-lived links.

Subprocess isolation ​

Your backend runs as its own subprocess with a minimal environment — only the variables the runtime sets (PLUGIN_SLUG, PLUGIN_DATA_DIR, PLUGIN_API_VERSION, NODE_OPTIONS, NODE_PATH, PATH/HOME, and the variable named by your manifest's data.dir_env if you declared one). The host's own environment is not inherited, so host and Central secrets never reach the plugin. The working directory is pinned to your plugin folder; stdin is owned by the IPC transport (don't read it).

On Linux the runtime spawns that subprocess inside a kernel sandbox (seccomp + Landlock) that enforces the capability model at the OS level, not just in the SDK. It is fail-closed: a Linux host that cannot establish the jail refuses to start the plugin rather than running it unsandboxed.

  • Filesystem — a Landlock jail: your plugin folder is read+execute, your data directory is read+write but no-exec (so a plugin cannot drop a binary into its data dir and run it). It cannot read the server's config/secrets or another plugin's database off disk. (plugin.files.* is still the supported way to store files.)
  • Network — the backend cannot open IP sockets. AF_INET, AF_INET6, AF_PACKET and AF_NETLINK are refused at socket() with EAFNOSUPPORT; only AF_UNIX stays open, for the runtime's own local transport. All outbound traffic goes through plugin.fetch or a declared proxy mount — there is no raw fetch / node:net escape hatch.
  • Process — NO_NEW_PRIVS, rlimits, and a denylist covering namespace, mount, ptrace and similar escape syscalls.

The rule of thumb: code against the SDK, not around it. A plugin already using plugin.fetch and plugin.files is unaffected; one reaching the network or filesystem directly will find those paths closed.

Frontend trust boundary ​

The panel iframe is sandboxed with an opaque origin and authenticated by an origin-verified handshake:

  • createPluginFrontend() derives the shell's origin and rejects any inbound postMessage whose event.origin doesn't match. Outbound messages always target that exact origin — never *.
  • File and proxy requests carry a per-session bearer token issued on handshake.

Because the origin is opaque, treat the frontend as untrusted for authorization. Never trust a user id, role, or permission decision that originates in the frontend — the backend's user argument (established by the runtime from the WebSocket session) is the only authority. Re-check every privileged action server-side in your handler.

Your responsibilities ​

The platform gives you isolation and gating; correctness inside your plugin is yours:

  • Validate every params field in a handler — it's unknown for a reason.
  • Authorize in the backend, not the frontend (hide-the-button is UX, not security). Use plugin.permissions.
  • Bind writes to user.id, never to a client-supplied id.
  • Parameterize SQL — always pass values as ? params, never string-concat into the query.