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 — sdk.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 (sdk.fetch)

sdk.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 lockhttp: / https: only.
  • No redirects — responses are returned as-is (redirect: "manual"); a 30x doesn't silently follow to a new host.
  • Header hygieneHost and Cookie are always stripped; Authorization is stripped on Central-targeted requests.
  • Bounds — 30 s timeout, 10 MB response cap.

Homelab note. UnCorded runs on the owner's own hardware, so reaching a LAN or localhost service (Home Assistant, a local game server) is a legitimate, supported use — there is no blanket "internal IP" block. The protections above plus the container's outbound firewall are the boundary. If you self-host and install a plugin, you are trusting it with the hosts it declares; the declared http.fetch:<host> list in its manifest is exactly what to audit.

File storage is jailed

sdk.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, plus PATH/HOME). Host and Central secrets are not inherited into the plugin's environment. 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:

  • Filesystem — the backend reads its own code and reads/writes only its own data directory. It cannot read the server's config/secrets or another plugin's database off disk. (sdk.files.* is still the supported way to store files.)
  • Network — the backend cannot open sockets directly. All outbound traffic goes through sdk.fetch (host-allowlisted) or a declared proxy mount; there is no raw fetch/node:net escape hatch.
  • Process — namespace/mount/ptrace and similar escape syscalls are blocked.

The rule of thumb: code against the SDK, not around it. A plugin already using sdk.fetch and sdk.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.