fix: harden the plugin runtime after the 2.9.0 security review

This commit is contained in:
LukeGus committed 2026-09-25 08:48:54 -05:00
1 parent 2961297989
commit c6a44b7ff3
69 files changed
+4613 -312

No files matched your search

+234 -46
View File
@@ -252,7 +252,10 @@ handler)` for a socket. Core runs the same middleware in front of every plugin:
1. **Auth**, unless the path is listed in `options.public`. A public path is
audited when the router is registered and logged on every request, because
"this plugin opened a hole in auth" should be findable later. It matches the
"this plugin opened a hole in auth" should be findable later. **D2** lists
a running plugin's public HTTP paths and public socket paths in the admin
plugin dialog (`publicRoutes` on `GET /plugins`), and audits a public
socket when it is registered. It matches the
full path, with `:param` segments allowed, so declaring `/webhook/:token`
public cannot open `/webhook/:token/anything`. A trailing `/*` is the one
deliberate prefix (**C3**): `/chooser/:id/*` opens that path and everything
@@ -260,12 +263,19 @@ handler)` for a socket. Core runs the same middleware in front of every plugin:
2. **The actor**, from the user core authenticated, never from the request body.
3. **An enabled check** returning 503 while the plugin is disabled. That is a
different fact from 404 ("no such plugin") and a caller can act on it.
A disabled plugin has no router left, so the dispatcher answers 503 for any
installed id and 404 only for an unknown one; sockets do the same on the
upgrade (**D2**).
4. **Body limits**, defaulting to the 2mb core itself accepts. `database.ts`
skips its own global parser for `/plugin-api` so a plugin's `bodyLimit`
actually applies; `rawBody` turns the parsers off for a router that parses
its own (multer, a raw body for signature checks).
5. **An error wrapper** that logs against the plugin, never leaks a stack, and
feeds the error budget.
feeds the error budget. A 4xx (a body that does not parse, one over the
limit) answers its own status and does not count, and nothing an
unauthenticated request causes counts either, so five junk requests to a
public route cannot switch a plugin off (**D2**). A public socket's guest
runs with no actor at all, the same as a public HTTP route.
`ctx.rbac.require(permission)` adds a per-route RBAC gate, and a plugin may only
require a permission it declares itself.
@@ -1111,6 +1121,11 @@ is disposed automatically on deactivate. See [Lifecycle rules](#lifecycle-rules)
### 11. Tests
The security regressions for the runtime live in
`src/backend/tests/plugins/security/` (one file per area of the D2 review) and
`src/ui/tests/plugin-host/security/`. A fix to plugin code gets its test in
that plugin's own `tests/`, like everything else.
Each plugin has `plugins/<id>/tests/backend` (node) and
`plugins/<id>/tests/frontend` (jsdom), run by its own vitest config:
@@ -1725,6 +1740,22 @@ fields are rejected at every level: a typo'd `contribute` used to validate
clean and be silently dropped, which made a manifest describe something it did
not do.
**D2** added the discovery rules:
- `RESERVED_PLUGIN_IDS` (`admin`, `hosts`, `credentials`, `core`, `termix`,
`plugin`, `plugins`, `users`, `system`, `sdk`) are refused. A plugin's
permissions are registered as `<id>.<name>`, so a plugin called `admin`
would otherwise mint `admin.*` permissions.
- `backend`, `frontend` and `locales` must be relative paths inside the
plugin: no absolute path, no `..`, no backslash. The loader also realpaths
the backend entry and every migration file and refuses one that resolves
outside the plugin, so a symlink cannot point elsewhere.
- A plugin whose table prefix overlaps a loaded plugin's (`foo` and
`foo-bar` both own `p_foo_bar_*`) is refused, bundled first.
- A user plugin can never take the id of a bundled plugin folder, even when
the bundled one failed to load.
- A `uiPresets` key must be a plain property name.
```jsonc
{
"id": "example", // ^[a-z][a-z0-9-]{1,39}$, equals the directory name
@@ -1979,48 +2010,49 @@ Built per plugin in `src/backend/plugins/ctx.ts` and passed to `activate`.
Every member is listed; the types in `@termix/plugin-sdk/backend` are the
authority for signatures.
| Member | Capability | What it does |
| -------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------- | ------------------------------------------------------------------------------------------------------- |
| `pluginId`, `manifest` | none | The plugin's id and parsed manifest |
| `log.debug/info/warn/error` | none | Lines in core's log, tagged with the plugin |
| `events.emit/on` | `events:core` for core topics | Topics under `plugin.<id>.*`, or core's (`user.deleted`, `user.data_wiped`, host events) |
| `kv.get/set/delete/list` | `kv:own` | Small key/value state |
| `files.dataDir()` | `files:own` | A per-plugin folder under `DATA_DIR` |
| `db.define/client/refs/persist/dialect` | `db:own` (not `dialect`) | The plugin's own tables through Drizzle; `persist` flushes SQLite to disk |
| `sync.registerEntity/recordTombstone` | none | Adds a table to remote sync, and records a delete for it |
| `registry.provide/consume/revoke` | none | The low-level named registry services sit on |
| `services.provide/get/providers` | per-service RBAC | Typed, versioned cross-plugin services, checked per call |
| `secrets.get/set/delete/seal/unseal` | `secrets:own` | The plugin's own encrypted values |
| `secrets.offer/withdraw/getShared` | per-secret RBAC | Secrets one plugin lends another |
| `http.router(options)`, `http.baseUrl(req)` | `network:serve` (router) | The router mounted at `/plugin-api/<id>/`, and the public origin for callback URLs |
| `ws.route/upgrade` | `network:serve` | Sockets at `/plugin-ws/<id>/<path>` |
| `rbac.has/hasFor/require` | own permissions (require) | The acting user's role permissions; `require` is route middleware |
| `settings.get/set/getUser/setUser/getHost/setHost/listHostValues/getAll/onChange/onValidate` | `hosts:read` (listHostValues) | The plugin's admin, user and host settings from its manifest |
| `settings.readCore(key)` | `settings:read-core` | One of the core keys in `CORE_SETTINGS_ALLOWLIST` |
| `disposables.add` | none | Extra cleanup on deactivate |
| `capabilities.has/require` | the named capability | A generic check for privileged code no ctx member wraps |
| `hosts.list/get/checkAccess` | `hosts:read` | Hosts the acting user can see, without secrets |
| `hosts.create/update/delete/listOwned/share/listUsers/listRoles` | `hosts:write` | Host changes and sharing on the user's behalf |
| `hosts.trackSession/recordActivity` | `hosts:read` | The online indicator and a recent activity entry |
| `hosts.status.get/check/reportLogin/registerPort` | `hosts:read` | Core's reachability status |
| `ssh.connect/withConnection/jumpChain` | `ssh:connect`, `credentials:use` | Connections through core's one connect pipeline |
| `ssh.resolveHost/prepare/openTransport/classifyKeyboardInteractive/autoResponses/startInteraction/cancelInteraction` | `ssh:connect`, `credentials:use` | The lower level, for a transport with its own prompt flow |
| `ssh.poolKey/dropPooled/requiresSecret/supportsBackground` | `ssh:connect` | Pool housekeeping and questions for an auth type's provider |
| `auth.registerLoginMethod/registerSecondFactor/recordEnrollment/removeEnrollment` | `auth:provide` | Login methods and second factors |
| `auth.completeRedirectLogin/revokeSessions/loginRateLimit/countLinkedUsers` | `auth:provide` | What a login method needs from core's session and provisioning code |
| `auth.registerSshAuthProvider/registerKeyboardInteractiveHandler` | `auth:provide` | SSH auth types and keyboard-interactive handlers |
| `credentials.listSshKeys` / `.createSshKey` | `credentials:use` / `credentials:write` | The user's saved SSH keys |
| `credentials.resolveHostProtocol` | `credentials:read` | A host's RDP, VNC or Telnet login |
| `credentials.registerSecretResolver` | `auth:provide` | Resolves external secret references in host and credential fields |
| `desktop.openIsolatedWindow/launchNativeRdp/available` | `desktop:window` (not `available`) | Electron windows and the native RDP client |
| `audit.record` | none | An audit line under the plugin's own action name |
| `schedule.every/after` | none | Timers that stop on deactivate and never overlap |
| `notify.channels/send` | `notify:send` | Core's notification channels, as the acting user |
| `fetch(url, init)` | `network:outbound` | Outbound HTTP through core's SSRF guard; `allowPrivateHosts` hosts also go through the configured proxy |
| `process.run/ensureBinary` | `process:spawn` (+ `network:outbound` to download) | Programs on the server |
| `system.tlsStatus/writeTlsCertificate/reloadTls/publishHttpChallenge/registerTlsRenewer` | `system:tls` | The server certificate |
| `asUser(userId, fn)` | none, always audited | Background work as a user |
| `currentActor()` | none | The acting user for the current call |
| Member | Capability | What it does |
| ------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------- |
| `pluginId`, `manifest` | none | The plugin's id and parsed manifest |
| `log.debug/info/warn/error` | none | Lines in core's log, tagged with the plugin |
| `events.emit/on` | `events:core` for core topics | Topics under `plugin.<id>.*`, or core's (`user.deleted`, `user.data_wiped`, host events) |
| `kv.get/set/delete/list` | `kv:own` | Small key/value state |
| `files.dataDir()` | `files:own` | A per-plugin folder under `DATA_DIR` |
| `db.define/client/refs/persist/dialect` | `db:own` (not `dialect`) | The plugin's own tables through Drizzle; `persist` flushes SQLite to disk |
| `sync.registerEntity/recordTombstone` | none | Adds a table to remote sync, and records a delete for it |
| `registry.provide/consume/revoke` | none | The low-level named registry services sit on |
| `services.provide/get/providers` | per-service RBAC | Typed, versioned cross-plugin services, checked per call |
| `secrets.get/set/delete/seal/unseal` | `secrets:own` | The plugin's own encrypted values |
| `secrets.offer/withdraw/getShared` | per-secret RBAC | Secrets one plugin lends another |
| `http.router(options)`, `http.baseUrl(req)` | `network:serve` (router) | The router mounted at `/plugin-api/<id>/`, and the public origin for callback URLs |
| `ws.route/upgrade` | `network:serve` | Sockets at `/plugin-ws/<id>/<path>` |
| `rbac.has/hasFor/require` | own permissions (require) | The acting user's role permissions; `require` is route middleware |
| `settings.get/set/getUser/setUser/getHost/setHost/listHostValues/getAll/onChange/onValidate` | `hosts:read` (listHostValues) | The plugin's admin, user and host settings from its manifest |
| `settings.readCore(key)` | `settings:read-core` | One of the core keys in `CORE_SETTINGS_ALLOWLIST` |
| `disposables.add` | none | Extra cleanup on deactivate |
| `capabilities.has/require` | the named capability | A generic check for privileged code no ctx member wraps |
| `hosts.list/get/checkAccess` | `hosts:read` | Hosts the acting user can see, without secrets |
| `hosts.create/update/delete/listOwned/share/listUsers/listRoles` | `hosts:write` | Host changes and sharing on the user's behalf |
| `hosts.trackSession/recordActivity` | `hosts:read` | The online indicator and a recent activity entry |
| `hosts.status.get/check/reportLogin/registerPort` | `hosts:read` | Core's reachability status (`get` is async since **D2**) |
| `ssh.connect/withConnection/jumpChain` | `ssh:connect`, `credentials:use` | Connections through core's one connect pipeline |
| `ssh.resolveHost/openTransport/classifyKeyboardInteractive/autoResponses/startInteraction/cancelInteraction` | `ssh:connect`, `credentials:use` | The lower level, for a transport with its own prompt flow; `resolveHost` redacts secrets without `credentials:read` |
| `ssh.prepare` | `ssh:connect`, `credentials:use`, `credentials:read` | The ssh2 config for a transport that connects its own client; it carries the password and key |
| `ssh.poolKey/dropPooled/requiresSecret/supportsBackground` | `ssh:connect` | Pool housekeeping and questions for an auth type's provider |
| `auth.registerLoginMethod/registerSecondFactor/recordEnrollment/removeEnrollment` | `auth:provide` | Login methods and second factors |
| `auth.completeRedirectLogin/revokeSessions/loginRateLimit/countLinkedUsers` | `auth:provide` | What a login method needs from core's session and provisioning code |
| `auth.registerSshAuthProvider/registerKeyboardInteractiveHandler` | `auth:provide` | SSH auth types and keyboard-interactive handlers |
| `credentials.listSshKeys` / `.createSshKey` | `credentials:use` / `credentials:write` | The user's saved SSH keys |
| `credentials.resolveHostProtocol` | `credentials:read` | A host's RDP, VNC or Telnet login |
| `credentials.registerSecretResolver` | `auth:provide` | Resolves external secret references in host and credential fields |
| `desktop.openIsolatedWindow/launchNativeRdp/available` | `desktop:window` (not `available`) | Electron windows and the native RDP client |
| `audit.record` | none | An audit line under the plugin's own action name |
| `schedule.every/after` | none | Timers that stop on deactivate and never overlap |
| `notify.channels/send` | `notify:send` | Core's notification channels, as the acting user |
| `fetch(url, init)` | `network:outbound` | Outbound HTTP through core's SSRF guard; `allowPrivateHosts` hosts also go through the configured proxy |
| `process.run/ensureBinary` | `process:spawn` (+ `network:outbound` to download) | Programs on the server |
| `system.tlsStatus/writeTlsCertificate/reloadTls/publishHttpChallenge/registerTlsRenewer` | `system:tls` | The server certificate |
| `asUser(userId, fn)` | none, always audited | Background work as a user |
| `currentActor()` | none | The acting user for the current call |
**B11** added `ctx.capabilities.has(capability)` / `.require(capability)`, a
generic check for a capability no other ctx member wraps. Unlike every other
@@ -2341,7 +2373,7 @@ Windows guest as RDP) without core naming either.
### The actor
Never comes from plugin code. It is held in `AsyncLocalStorage` and set two
ways, with no third:
ways, with no third (a public socket's guest has none):
- **A request.** A4's HTTP and WebSocket middleware runs the handler as the
user core already authenticated.
@@ -2352,6 +2384,68 @@ A plugin cannot pass a user id to a guarded method and have it believed. This
is what closes the forged-caller bug the old worker broker had, where a worker
could set `callerUserId` on its own messages.
Two calls do take a user id, for background work that reaches another
plugin: `ctx.services.get(name, { userId })` (and a handle's
`asUser(userId)`) and `ctx.secrets.getShared(..., { userId })`. Naming anyone
but the current actor there is the same act as `ctx.asUser`, so since **D2**
it writes the same `plugin_as_user` audit line.
`ctx.ssh` never believes a host object's own `userId` either. Before **D2**
a call with no actor ran as whatever `userId` the plugin put on the host it
passed, which reached that user's jump hosts with that user's credentials.
Now every `ctx.ssh` call needs an actor, except for a host core itself
resolved earlier (from `resolveHost`, `connect` or `withConnection`), which
keeps the user it was resolved for. Any other object host runs as the actor.
**D2** also put the per-user access check on `hosts.status.*`,
`hosts.trackSession`, `hosts.recordActivity` and `ssh.startInteraction`: with
an actor, a host the actor cannot connect to answers nothing. With no actor
they are the plugin's own background work (a poller, a guacd session opening)
and only the capability applies.
### Credentials
Only a plugin holding `credentials:read` sees plaintext credential material
(`HOST_SECRET_FIELDS` in `ctx-hosts.ts`: passwords, keys, passphrases, sudo
and SOCKS passwords, the autostart ones and `terminalConfig.sudoPassword`).
Since **D2**:
- `ctx.hosts` always returns hosts with those removed, `listOwned` included,
and `listOwned` is audited. `update` never moves `id`, `userId` or
`syncId`.
- `ctx.ssh.connect().host` is always redacted.
- `ctx.ssh.resolveHost` returns the full host, audited as
`plugin_credentials_read`, only to a plugin with `credentials:read`. Any
other plugin gets the redacted host, and core remembers the real one, so
handing that object back to `connect`, `withConnection` or `prepare`
connects with the real secrets.
- `ctx.ssh.prepare` needs `credentials:read`, because the ssh2 config it
returns carries them.
ssh-terminal, file-manager, host-metrics and tailscale declare
`credentials:read`: they run `prepare` or read a sudo password.
`credentials.resolveHostProtocol` was already behind it.
### Refusals
`assertCapability` writes the refusal's audit line itself, named after the
call (`plugin_kv_set`), so no guard can forget to. A synchronous declaration
check writes it in the background through `capabilityRefused`. Successes are
audited for every privileged call except the hot-path reads a plugin makes on
every request or sample: `kv.get`, `secrets.get/seal/unseal`, `db.persist`,
`hosts.list/get/checkAccess`, `hosts.status.*` and `hosts.trackSession`.
`src/backend/tests/plugins/security/ctx-guards.test.ts` walks the whole ctx
object and fails for any member that runs without a capability and is not on
its `UNGATED` list with a reason.
Hooks ssh2 calls synchronously (a provider's `onBanner` and `onAuthFailed`,
keyboard-interactive `detect` and `autoAnswerPasswords`) answer from the
grant cache and do nothing until the grant is confirmed.
`ctx.process.run` gives the program a small base environment (path, home,
temp, locale, proxy) plus the plugin's own `env`, never the server's, which
holds the JWT secret and database keys.
---
## Lifecycle rules
@@ -2421,7 +2515,17 @@ architecture did.
`ctx.db` is a clear example. It hands back a handle scoped to the plugin's own
tables, and the `p_<id>_` prefix, the CLI check and the runner's check all say
a plugin may only touch its own. None of that is enforced by the engine: a
a plugin may only touch its own. Since **D2** both checks are one function,
`findUnownedTableWrites` in `@termix/plugin-sdk/ddl`, which reads each
statement the way the engine does: comments and every quoting style are
seen through, every name in a `DROP TABLE a, b` list is checked, a rename
target counts as a write, reads (`FROM`, `JOIN`, `REFERENCES`) are allowed,
and `ATTACH`, `PRAGMA`, `GRANT`, triggers, functions and other statement kinds
are refused outright. The legacy-table exemption applies only to the bundled
plugin that adopts the table. `removePluginData` finds a plugin's tables in
the database's own catalog by prefix, leaves a longer prefix another plugin
owns, and takes the plugin's settings and secrets with it; role permissions
stay. None of that is enforced by the engine: a
plugin holding that handle can write any table in the database, and `ctx.db.refs`
hands it `users`, `ssh_data`, `roles` and `user_roles` outright (the last two
added in **B2**, for a plugin that shares its own rows with a role and needs
@@ -2442,6 +2546,90 @@ until one is, this document does not pretend otherwise.
---
## Threat model
Written in **D2**, after a security review of the whole plugin system. The
short version: a plugin is code you run on your server, with the same reach
as Termix itself. Everything below follows from that.
### What a malicious plugin can do
A plugin runs in the Termix server process and in the browser tab of anyone
who uses Termix. In the server it can do everything the server's OS user can:
- read `DATA_DIR`, which holds the database, the system key, the JWT secret
and every user's encrypted data, and import core modules to decrypt it;
- read and write any table, whatever `ctx.db` says it owns;
- open sockets, listen on ports, spawn programs and read the environment;
- replace or patch core modules in memory, including the auth middleware and
the capability check itself;
- log in as any user, because `ctx.auth` identities and `ctx.asUser` are
trusted by design.
In the browser it runs in the shell's own JavaScript context, so it can read
anything the page can, including the session cookie's effects and every
other plugin's UI.
None of the checks in this document stop that. They are not a sandbox.
### What the system does about it
- **Signing and review.** Official plugins live in this repo and ship in the
Termix image; they are reviewed like core. Signed community plugins and
their review process arrive with the install UI in 3.0.0.
- **The kill list (3.0.0).** A signed list of plugin versions known to be bad,
which the server refuses to load, so a bad plugin can be switched off on
every install without waiting for each admin.
- **Capability consent.** A manifest names every privileged thing its code
does through `ctx`, with a risk level. In 3.0.0 an admin grants each one
when installing; in 2.9.0 bundled plugins get what they declare.
- **Audit.** Every privileged `ctx` call, allowed or refused, writes a line
with the plugin id and the acting user, and so does every switch of the
actor (`ctx.asUser`, or a service handle or shared secret named for another
user). "What did this plugin do, as whom" has an answer.
- **Honest plugins stay honest.** The checks D2 added or confirmed stop the
accidental and the confused-deputy cases, which are the ones that actually
happen:
- every privileged member is guarded, and a test walks `ctx` so a new
member without a guard fails CI;
- plaintext credentials only reach a plugin holding `credentials:read`;
- a host object's `userId` is never believed, so a plugin cannot borrow
another user's jump hosts or credentials by naming them;
- `ctx.hosts` and `ctx.ssh` apply the actor's RBAC to every host;
- routes need login unless declared public, public routes are listed in
the admin plugin dialog, a disabled plugin answers 503, errors hide their
stack, and an anonymous caller cannot spend a plugin's error budget;
- `/plugin-assets` serves only a running plugin's browser files, with no
way out of the plugin folder, symlinks included;
- a user plugin cannot take a bundled plugin's id, a reserved id (`admin`,
`hosts`, `credentials` and a few more) or an id whose table prefix
overlaps another plugin's;
- migrations are parsed, not pattern-matched, and may only write the
plugin's own tables;
- a plugin's programs get a small environment without the server's
secrets.
### Before installing a community plugin
- **Know where it came from.** Install from a source you would trust with
root on the server, and check the signature once 3.0.0 has them.
- **Read the capabilities.** Anything critical or high (`credentials:read`,
`system:tls`, `ssh:connect`, `process:spawn`, `users:write`,
`auth:provide`, `device:serial`) means the plugin can reach your hosts,
your users or the server itself. Ask why it needs each one.
- **Read the public routes.** Admin > Plugins > Permissions lists every path
it serves without login. Each one is attack surface on your server.
- **Check its dependencies.** A plugin's npm dependencies run with the same
reach as the plugin.
- **Watch the audit log after installing.** Filter on `plugin:<id>` and look
for calls you did not expect, especially `plugin_as_user` and
`plugin_credentials_read`.
- **Run Termix as an unprivileged user**, in its container or under its own
OS account, so "everything the server user can do" is as little as
possible.
---
## Building a plugin
`termix-plugin`, the CLI the SDK ships, is the only build a plugin needs. It
+22
View File
@@ -167,6 +167,18 @@ with a real install, a real device or a repo outside this one.
import that carries file manager bookmarks; the Simple preset hiding plugin
rail items except Snippets.
- Termix-Mobile: after a redirect login (SSO) with the admin setting
"second factor after external login" on, the server answers with the
second-factor step (pending cookie and `temp_token`) instead of a session.
The mobile app has to handle that step; that change is in the mobile repo.
- D2 changes to check by hand: a remote desktop session still opens, and a
touch mode switch within five minutes reuses its token (after that the app
mints a new one); the terminal, file manager and host metrics still connect
and sudo still works (they now declare `credentials:read`); Docker and
automations connect with a redacted host handed back to core; Admin >
Plugins > Permissions lists a plugin's public routes; a disabled plugin's
`/plugin-api` URL answers 503; a share-link guest who leaves cannot type.
## Left for later
- 3.0.0: drop the migrated 2.8 settings rows (`guac_url`, `step_ca_url`,
@@ -183,3 +195,13 @@ with a real install, a real device or a repo outside this one.
rule: `src/backend/hosts/host-session-status.test.ts`,
`jump-host-proxy.test.ts` and `ssh-keepalive.test.ts`. Move them under
`src/backend/tests/hosts/`.
- D2 low findings, not fixed: plugin sockets have no Origin check (a
SameSite=lax cookie is not sent on a cross-site WebSocket, so only a
sibling subdomain could try); a public `:param` path matches every HTTP
method and would also match a sibling literal route; `/plugins/public-manifest`
shows plugin versions before login; `ctx.process.ensureBinary` hashes any
`prebuilt` path on disk; `credentials.resolveHostProtocol` falls back to the
SSH password for an RDP login (2.8 behaviour, behind `credentials:read`);
a remote desktop display token can be replayed within its five minute TTL
(strict single use would break the touch mode switch and reconnect, which
reuse it). Revisit with signing and the install UI in 3.0.0.
+8 -13
View File
@@ -3,6 +3,7 @@ import path from "node:path";
import { fileURLToPath } from "node:url";
import { readManifest } from "../lib/plugin-dir.mjs";
import { LEGACY_TABLE_OWNERS as LEGACY_TABLES } from "../../dist/db.js";
import { findUnownedTableWrites } from "../../dist/ddl.js";
/**
* The manifest rules live in src/manifest.ts, which the server uses too, so
@@ -76,9 +77,6 @@ function validateNativeDependencies(cwd, raw) {
const DIALECTS = ["sqlite", "postgres", "mysql"];
const CREATE_OR_ALTER =
/(?:CREATE\s+(?:TEMP\s+|TEMPORARY\s+)?TABLE(?:\s+IF\s+NOT\s+EXISTS)?|ALTER\s+TABLE|DROP\s+TABLE(?:\s+IF\s+EXISTS)?|TRUNCATE\s+TABLE)\s+([`"']?)([a-zA-Z0-9_]+)\1/gi;
/**
* Checks that a plugin's migrations only touch tables it owns.
*
@@ -88,7 +86,11 @@ const CREATE_OR_ALTER =
*/
function validateMigrations(cwd, pluginId) {
const problems = [];
const prefix = `p_${String(pluginId).replace(/-/g, "_")}_`;
const legacy = new Set(
Object.entries(LEGACY_TABLES)
.filter(([, owner]) => owner === pluginId)
.map(([table]) => table),
);
const root = path.join(cwd, "migrations");
if (!fs.existsSync(root)) return problems;
@@ -113,15 +115,8 @@ function validateMigrations(cwd, pluginId) {
}
const sql = fs.readFileSync(path.join(dir, file), "utf8");
CREATE_OR_ALTER.lastIndex = 0;
let match;
while ((match = CREATE_OR_ALTER.exec(sql)) !== null) {
const table = match[2];
if (table.startsWith(prefix)) continue;
if (LEGACY_TABLES[table] === pluginId) continue;
problems.push(
`migrations/${dialect}/${file} touches "${table}", which is not prefixed "${prefix}"`,
);
for (const problem of findUnownedTableWrites(pluginId, sql, legacy)) {
problems.push(`migrations/${dialect}/${file} ${problem}`);
}
}
}
+6 -3
View File
@@ -699,8 +699,11 @@ export interface PluginHostStatusEntry {
* turns "reachable" into "online".
*/
export interface PluginHostStatusApi {
/** The last known status, or null when core has not checked the host yet. */
get: (hostId: number) => PluginHostStatusEntry | null;
/**
* The last known status, or null when core has not checked the host yet or
* the acting user cannot reach it.
*/
get: (hostId: number) => Promise<PluginHostStatusEntry | null>;
/** Checks the host now unless core checked it recently. */
check: (hostId: number) => Promise<PluginHostStatusEntry | null>;
/** Reports a login attempt. Not audited: pollers call it every sample. */
@@ -1588,7 +1591,7 @@ export interface PluginCapabilities {
}
export interface PluginProcessOptions {
/** Added to the server's environment. */
/** Added to a small base environment (PATH, HOME, temp, locale, proxy), never the whole server one. */
env?: Record<string, string>;
cwd?: string;
/** Kills the program with SIGKILL after this long. */
+251 -1
View File
@@ -10,7 +10,7 @@
* core's schema, kept deliberately in step with it.
*/
import { KEY_LENGTH, prefixedTableName } from "./db.js";
import { KEY_LENGTH, prefixedTableName, tablePrefix } from "./db.js";
import type {
PluginColumn,
PluginTableDefinition,
@@ -346,3 +346,253 @@ export function addColumnSql(
return `ALTER TABLE ${quote(dialect, physical)} ADD COLUMN ${line};`;
}
interface SqlToken {
/** As written: a bare word keeps its case, a quoted name loses its quotes. */
value: string;
kind: "word" | "quoted" | "punct";
}
/**
* Words and identifiers in one statement, with comments and string literals
* dropped. `"x"`, `` `x` `` and `[x]` all come out as the identifier x, so no
* quoting style hides a table name from the ownership check.
*/
function tokenize(source: string): SqlToken[] {
const tokens: SqlToken[] = [];
let i = 0;
while (i < source.length) {
const char = source[i];
const next = source[i + 1];
if (/\s/.test(char)) {
i++;
} else if (char === "-" && next === "-") {
while (i < source.length && source[i] !== "\n") i++;
} else if (char === "/" && next === "*") {
const end = source.indexOf("*/", i + 2);
i = end < 0 ? source.length : end + 2;
} else if (char === "'") {
i++;
while (i < source.length) {
if (source[i] === "'" && source[i + 1] === "'") i += 2;
else if (source[i] === "'") break;
else i++;
}
i++;
tokens.push({ value: "'", kind: "punct" });
} else if (char === '"' || char === "`" || char === "[") {
const close = char === "[" ? "]" : char;
let value = "";
i++;
while (i < source.length) {
if (source[i] === close && source[i + 1] === close && close !== "]") {
value += close;
i += 2;
} else if (source[i] === close) {
break;
} else {
value += source[i++];
}
}
i++;
tokens.push({ value, kind: "quoted" });
} else if (/[A-Za-z0-9_$]/.test(char)) {
let value = "";
while (i < source.length && /[A-Za-z0-9_$]/.test(source[i])) {
value += source[i++];
}
tokens.push({ value, kind: "word" });
} else {
tokens.push({ value: char, kind: "punct" });
i++;
}
}
return tokens;
}
/** Statements a plugin migration may never contain. */
const REFUSED_STATEMENTS = new Set([
"ATTACH",
"DETACH",
"PRAGMA",
"GRANT",
"REVOKE",
"COPY",
"LOAD",
"DO",
"CALL",
"HANDLER",
"WITH",
"SET",
"VACUUM",
]);
/** What a migration may CREATE, ALTER or DROP. Anything else is refused. */
const TABLE_OBJECTS = new Set(["TABLE", "VIEW"]);
const HARMLESS_OBJECTS = new Set(["SEQUENCE", "TYPE"]);
const CREATE_MODIFIERS = [
"OR",
"REPLACE",
"TEMP",
"TEMPORARY",
"UNLOGGED",
"GLOBAL",
"LOCAL",
"VIRTUAL",
"UNIQUE",
"CONCURRENTLY",
];
/**
* Every problem with the tables a migration writes to.
*
* The check has to hold against a plugin that is trying, not only one that
* made a typo, so it reads each statement the way the engine does rather
* than matching one pattern: a comment between a keyword and a name, bracket
* quoting, a second name in a DROP list, a schema-qualified name and
* `ALTER ... RENAME TO users` are all seen. Reads (FROM, JOIN, REFERENCES)
* are allowed; writes outside the plugin's prefix are not.
*
* `ownedLegacy` lists the legacy core tables this plugin adopts.
*/
export function findUnownedTableWrites(
pluginId: string,
sql: string,
ownedLegacy: ReadonlySet<string> = new Set(),
): string[] {
const prefix = tablePrefix(pluginId);
const problems: string[] = [];
for (const statement of splitStatements(sql)) {
const tokens = tokenize(statement);
if (tokens.length === 0) continue;
const upper = (at: number) =>
tokens[at]?.kind === "word" ? tokens[at].value.toUpperCase() : "";
/** Checks the table name at `at`, returns the index after it. */
const table = (at: number): number => {
const name = tokens[at];
if (!name || name.kind === "punct") {
problems.push("a statement writes to a table with no name");
return at + 1;
}
if (tokens[at + 1]?.value === ".") {
problems.push(
`"${name.value}.${tokens[at + 2]?.value ?? ""}" names a schema; a plugin may only write its own tables`,
);
return at + 3;
}
if (!name.value.startsWith(prefix) && !ownedLegacy.has(name.value)) {
problems.push(
`writes to "${name.value}", which is not prefixed "${prefix}"`,
);
}
return at + 1;
};
const skip = (at: number, words: string[]) => {
while (words.includes(upper(at))) at++;
return at;
};
const find = (word: string, from = 0) => {
for (let at = from; at < tokens.length; at++) {
if (upper(at) === word) return at;
}
return -1;
};
const first = upper(0);
if (REFUSED_STATEMENTS.has(first)) {
problems.push(`${first} is not allowed in a plugin migration`);
continue;
}
if (first === "CREATE" || first === "ALTER" || first === "DROP") {
let at = skip(1, CREATE_MODIFIERS);
const object = upper(at);
if (object === "INDEX") {
// The index name is free; the table after ON is what it writes to.
const on = find("ON", at);
if (on > 0) table(skip(on + 1, ["ONLY"]));
continue;
}
if (HARMLESS_OBJECTS.has(object)) continue;
if (!TABLE_OBJECTS.has(object)) {
problems.push(
`${first} ${object || tokens[at]?.value || ""} is not allowed in a plugin migration`.trim(),
);
continue;
}
at = table(skip(at + 1, ["IF", "NOT", "EXISTS", "ONLY"]));
// DROP TABLE a, b drops both.
while (first === "DROP" && tokens[at]?.value === ",") {
at = table(at + 1);
}
// ALTER TABLE p_x RENAME TO users moves a table out of the namespace.
// RENAME [COLUMN] a TO b renames a column and is fine.
for (let rename = find("RENAME", at); rename > 0;) {
const next = upper(rename + 1);
if (next === "TO" || next === "AS") table(rename + 2);
rename = find("RENAME", rename + 1);
}
continue;
}
if (first === "RENAME") {
// RENAME TABLE a TO b, c TO d
let at = skip(1, ["TABLE"]);
while (at < tokens.length) {
at = table(at);
if (upper(at) !== "TO") break;
at = table(at + 1);
if (tokens[at]?.value !== ",") break;
at++;
}
continue;
}
if (first === "TRUNCATE") {
let at = table(skip(1, ["TABLE", "ONLY"]));
while (tokens[at]?.value === ",") at = table(at + 1);
continue;
}
if (first === "INSERT" || first === "REPLACE") {
const into = find("INTO");
if (into > 0) table(into + 1);
else problems.push(`${first} without INTO is not allowed`);
continue;
}
if (first === "UPDATE") {
table(
skip(1, [
"OR",
"ROLLBACK",
"ABORT",
"REPLACE",
"FAIL",
"IGNORE",
"ONLY",
"LOW_PRIORITY",
]),
);
continue;
}
if (first === "DELETE") {
const from = find("FROM");
if (from > 0) table(skip(from + 1, ["ONLY"]));
else problems.push("DELETE without FROM is not allowed");
continue;
}
if (first !== "SELECT" && first !== "COMMENT") {
problems.push(
`"${tokens[0].value}" statements are not allowed in a plugin migration`,
);
}
}
return problems;
}
+57 -3
View File
@@ -37,6 +37,27 @@ export type ActionContributionKind = (typeof ACTION_CONTRIBUTION_KINDS)[number];
const OPEN_FROM_VALUES = ["rail", "host-context-menu", "palette"] as const;
const ID_PATTERN = /^[a-z][a-z0-9-]{1,39}$/;
/**
* Ids no plugin may take. A permission is registered as <id>.<name>, so a
* plugin called "admin" would mint admin.* permissions and could default them
* onto the user role; the rest are names core or the runtime already uses.
*/
export const RESERVED_PLUGIN_IDS: readonly string[] = [
"admin",
"hosts",
"credentials",
"core",
"termix",
"plugin",
"plugins",
"users",
"system",
"sdk",
];
/** A preset key is a plain property name, never a prototype one. */
const PRESET_KEY_PATTERN = /^[a-zA-Z][a-zA-Z0-9_]{0,63}$/;
const SEMVER_PATTERN = /^\d+\.\d+\.\d+(-[0-9A-Za-z-.]+)?(\+[0-9A-Za-z-.]+)?$/;
const API_VERSION_PATTERN = /^[0-9]+$/;
/** Service names are dotted, e.g. "ssh.transport". */
@@ -476,6 +497,30 @@ function rejectUnknown(
}
}
/**
* A path inside the plugin folder. Absolute paths, ".." and backslashes are
* refused so an entry point or asset folder cannot point outside the plugin.
*/
function requireRelativePath(
value: string,
where: string,
errors: string[],
): void {
const segments = value.split("/");
if (
value.startsWith("/") ||
/^[A-Za-z]:/.test(value) ||
value.includes("\\") ||
value.includes("\0") ||
segments.includes("..") ||
segments.every((segment) => segment === "" || segment === ".")
) {
errors.push(
`Field "${where}" must be a relative path inside the plugin, got: ${JSON.stringify(value)}`,
);
}
}
function requireString(
value: unknown,
where: string,
@@ -507,6 +552,8 @@ export function validateManifest(manifest: unknown): string[] {
errors.push(
`Field "id" must match ${ID_PATTERN} (lowercase, starts with a letter, 2-40 chars), got: ${JSON.stringify(m.id)}`,
);
} else if (RESERVED_PLUGIN_IDS.includes(m.id)) {
errors.push(`Field "id" may not be "${m.id}": that name is reserved`);
}
}
@@ -524,9 +571,11 @@ export function validateManifest(manifest: unknown): string[] {
if ("repository" in m) requireString(m.repository, "repository", errors);
if ("icon" in m) requireString(m.icon, "icon", errors);
if ("backend" in m) requireString(m.backend, "backend", errors);
if ("frontend" in m) requireString(m.frontend, "frontend", errors);
if ("locales" in m) requireString(m.locales, "locales", errors);
for (const field of ["backend", "frontend", "locales"] as const) {
if (field in m && requireString(m[field], field, errors)) {
requireRelativePath(m[field] as string, field, errors);
}
}
validateAuthor(m.author, errors);
validateEngine(m.engine, errors);
@@ -870,6 +919,11 @@ function validateUiPresets(value: unknown, errors: string[]): void {
continue;
}
for (const [key, entry] of Object.entries(preset)) {
if (!PRESET_KEY_PATTERN.test(key)) {
errors.push(
`${where}.${level} has an invalid key ${JSON.stringify(key)}`,
);
}
if (!isPresetValue(entry)) {
errors.push(
`${where}.${level}.${key} must be a boolean, number, string or string array`,
+2 -2
View File
@@ -885,7 +885,7 @@ export function createFakeContext(
activities.push({ hostId, type, hostName });
},
status: {
get: (hostId) => hostStatuses.get(hostId) ?? null,
get: async (hostId) => hostStatuses.get(hostId) ?? null,
check: async (hostId) => hostStatuses.get(hostId) ?? null,
reportLogin: (hostId, outcome) => {
statusReports.push({ hostId, ...outcome });
@@ -1546,7 +1546,7 @@ export function createMockCtx(
return ctx.hosts.recordActivity(hostId, type, hostName);
},
status: {
get: (hostId) => {
get: async (hostId) => {
require("hosts:read");
return ctx.hosts.status.get(hostId);
},