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.fetchto a host you didn't declare is denied. - Wildcards are constrained at manifest-validation time:
data.read:*andevents.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:selfoperates 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 inpublic_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 —_configfor 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_configanyway — 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 lock —
http:/https:only. - No redirects — responses are returned as-is (
redirect: "manual"); a30xdoesn't silently follow to a new host. - Header hygiene —
HostandCookieare always stripped;Authorizationis 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
localhostservice (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 declaredhttp.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 insideuploads/. - 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 rawfetch/node:netescape 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 inboundpostMessagewhoseevent.origindoesn'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
paramsfield in a handler — it'sunknownfor 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.