mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-30 15:15:24 +00:00
fix: harden the plugin runtime after the 2.9.0 security review
This commit is contained in:
1 parent
2961297989
commit
c6a44b7ff3
69 files changed
+4613
-312
No files matched your search
@@ -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
|
||||
|
||||
@@ -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.
|
||||
@@ -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}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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`,
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
|
||||
Reference in new issue
Block a user