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

+3
View File
@@ -105,6 +105,9 @@ export async function evaluateSecondFactors(
try {
if (await factor.isEnrolled(userId)) required.set(key, factor);
} catch (error) {
// Unknown is not "not enrolled": the login stops instead of skipping
// a factor the user may well have.
blocked.push({ pluginId: factor.pluginId, factorId: factor.id });
authLogger.warn("Second factor enrolment check failed", {
operation: "second_factor_enrolment_check",
factorId: factor.id,
+51 -7
View File
@@ -163,10 +163,12 @@ async function findLinkedUser(
}
// Accounts from before user_external_identities matched on the raw
// identifier column; link them the first time they sign in.
// identifier column; link them the first time they sign in. The column is
// not scoped to a provider, so only an SSO account that has no link yet,
// from the same provider when both say which, can be claimed this way.
if (identity.legacyIdentifier) {
const legacy = await users.findByOidcIdentifier(identity.legacyIdentifier);
if (legacy) {
if (legacy && (await mayClaimLegacyAccount(legacy, identity))) {
await identities.linkIdentity({
userId: legacy.id,
providerId: identity.provider,
@@ -179,6 +181,42 @@ async function findLinkedUser(
return null;
}
async function mayClaimLegacyAccount(
legacy: UserRecord,
identity: ExternalIdentity,
): Promise<boolean> {
if (!legacy.isOidc) return false;
if (
legacy.ssoProviderId != null &&
identity.ssoProviderId != null &&
legacy.ssoProviderId !== identity.ssoProviderId
) {
return false;
}
const links = await createCurrentUserAuthRepository().listIdentitiesForUser(
legacy.id,
);
return links.length === 0;
}
/**
* A username nobody else has. An identity provider's display name must never
* give an SSO account the same username as a local one, such as "admin".
*/
async function freeUsername(
wanted: string,
selfId: string | null,
): Promise<string> {
const users = createCurrentUserRepository();
let candidate = wanted;
for (let attempt = 0; attempt < 5; attempt++) {
const holder = await users.findByUsername(candidate);
if (!holder || holder.id === selfId) return candidate;
candidate = `${wanted}-${nanoid(4)}`;
}
return `${wanted}-${nanoid(10)}`;
}
async function createUser(
identity: ExternalIdentity,
deviceType: string,
@@ -187,7 +225,7 @@ async function createUser(
const id = nanoid();
const created = await users.createFirstSsoUser({
id,
username: identity.name || identity.subject,
username: await freeUsername(identity.name || identity.subject, null),
passwordHash: "",
isAdmin: !!identity.isAdmin,
isOidc: true,
@@ -303,10 +341,16 @@ export async function findOrProvisionExternalUser(
// A user who also has a password picked their own username; leave it.
const isDualAuth = !!user.passwordHash && user.passwordHash.trim() !== "";
if (!isDualAuth && identity.name && user.username !== identity.name) {
user =
(await createCurrentUserRepository().update(user.id, {
username: identity.name,
})) ?? user;
// Taken by someone else: keep the name the account already has.
const holder = await createCurrentUserRepository().findByUsername(
identity.name,
);
if (!holder || holder.id === user.id) {
user =
(await createCurrentUserRepository().update(user.id, {
username: identity.name,
})) ?? user;
}
}
if (identity.isAdmin !== undefined) {
@@ -225,21 +225,27 @@ export async function withHostPluginSettings(
}
/**
* Writes a set of host-scope plugin settings in one call, for a core route
* that still accepts a plugin's fields inline on the host create/update body
* (the fields haven't grown their own editor UI flow yet). Values are
* JSON-stringified the same way ctx.settings.setHost stores them.
* Writes a set of host-scope plugin settings in one call, through the same
* path as every other settings write: an undeclared key is refused, a value
* is validated, and a secret field is encrypted.
*/
export async function writeHostPluginSettings(
pluginId: string,
manifest: PluginManifest,
hostId: number,
values: Record<string, unknown>,
): Promise<void> {
const repository = createCurrentPluginSettingsRepository();
const scopeId = String(hostId);
for (const [key, value] of Object.entries(values)) {
if (value === undefined) continue;
await repository.set(pluginId, "host", scopeId, key, JSON.stringify(value));
const error = await setSetting(manifest, "host", hostId, key, value);
if (error) {
sshLogger.warn("Refused a plugin host setting on import", {
operation: "host_import_plugin_settings",
pluginId: manifest.id,
hostId,
key,
error,
});
}
}
}
@@ -296,7 +302,7 @@ export async function applyPluginHostImportSettings(
`${manifest.id}.hostImportNormalizer`,
);
const values = normalizer?.(raw);
if (values) await writeHostPluginSettings(manifest.id, hostId, values);
if (values) await writeHostPluginSettings(manifest, hostId, values);
} catch (error) {
sshLogger.warn("Plugin host import normalizer failed", {
operation: "host_import_plugin_settings",
@@ -16,7 +16,7 @@
import express, { type Request, type Response } from "express";
import { databaseLogger } from "../../utils/logger.js";
import { getPluginRouter } from "../../plugins/http.js";
import { getPluginRouter, isPluginInstalled } from "../../plugins/http.js";
import { getRequestBasePath } from "../../utils/request-origin.js";
import type { PluginLegacyRedirect } from "@termix/plugin-sdk/manifest";
@@ -51,7 +51,7 @@ const router = express.Router();
* 401:
* description: Authentication required.
* 404:
* description: Plugin not installed or serves no routes.
* description: No plugin with this id is installed.
* 503:
* description: Plugin is installed but not running.
*/
@@ -60,17 +60,26 @@ router.use("/:pluginId", (req: Request, res: Response, next) => {
const pluginRouter = getPluginRouter(pluginId);
if (!pluginRouter) {
databaseLogger.warn("Plugin API request for unregistered plugin", {
operation: "plugin_api_dispatch",
pluginId,
});
res.status(404).json({ error: "Plugin not installed or not running" });
notServing(res, pluginId);
return;
}
pluginRouter(req, res, next);
});
/** 503 for an installed plugin that is off, 404 for an unknown id. */
function notServing(res: Response, pluginId: string): void {
if (isPluginInstalled(pluginId)) {
res.status(503).json({ error: "Plugin is not running", pluginId });
return;
}
databaseLogger.warn("Plugin API request for unregistered plugin", {
operation: "plugin_api_dispatch",
pluginId,
});
res.status(404).json({ error: "Plugin not installed" });
}
/**
* Mounts the dispatcher. No auth in front of it: each plugin router runs
* core auth itself and skips it only for the paths it declared public, so a
@@ -120,7 +129,7 @@ export function mountPluginLegacyPaths(
if (!plugin.legacyPaths.some((path) => under(req.path, path))) continue;
const pluginRouter = getPluginRouter(plugin.id);
if (!pluginRouter) {
res.status(404).json({ error: "Plugin not installed or not running" });
notServing(res, plugin.id);
return;
}
req.url = req.url.slice(plugin.id.length + 1) || "/";
+16 -4
View File
@@ -16,6 +16,8 @@ import { getPluginRuntime } from "../../plugins/index.js";
import { describePluginFrontend } from "../../plugins/assets.js";
import { getSetting } from "../../plugins/settings.js";
import { invalidatePluginPermissionCache } from "../../plugins/permissions.js";
import { getPluginPublicHttpRoutes } from "../../plugins/http.js";
import { getPluginPublicWsRoutes } from "../../plugins/ws.js";
import {
findField,
getAllSettings,
@@ -191,8 +193,10 @@ export async function listPreLoginPlugins(): Promise<
*
* Non-admins get only what the shell needs. What a plugin is allowed to
* do, what has been granted to it and why it failed are operational
* details, so capabilities, grants and lastError are included only for
* holders of admin.plugins.manage.
* details, so capabilities, grants, lastError and publicRoutes are
* included only for holders of admin.plugins.manage. publicRoutes lists
* the HTTP paths and socket paths a running plugin serves without
* core's login check.
*
* The frontend fields drive the browser's plugin loader: `frontend`
* says there is a bundle at /plugin-assets/<id>/frontend.js,
@@ -283,6 +287,10 @@ router.get("/", authenticateJWT, async (req: Request, res: Response) => {
capabilities,
grantedCapabilities: grantsByPlugin.get(record.id) ?? [],
lastError: loaded?.lastError ?? record.lastError ?? null,
publicRoutes: {
http: getPluginPublicHttpRoutes(record.id),
ws: getPluginPublicWsRoutes(record.id),
},
};
}),
);
@@ -640,7 +648,7 @@ router.delete(
* /plugins/{id}/data:
* delete:
* summary: Delete everything a plugin stores
* description: Drops the plugin's own tables and clears its key/value state, capability grants and migration ledger. Disabling a plugin never touches its data; this is the explicit uninstall path, and it cannot be undone. The plugin is deactivated first so nothing is writing while its tables go.
* description: Drops every table under the plugin's prefix and clears its key/value state, settings and secrets, capability grants and migration ledger. Role permissions stay. Disabling a plugin never touches its data; this is the explicit uninstall path, and it cannot be undone. The plugin is deactivated first so nothing is writing while its tables go.
* tags:
* - Plugins
* parameters:
@@ -680,7 +688,11 @@ router.delete(
}
const { removePluginData } = await import("../../plugins/data.js");
const removed = await removePluginData(pluginId);
const removed = await removePluginData(pluginId, {
knownPluginIds: getPluginRuntime()
.loader.list()
.map((plugin) => plugin.id),
});
databaseLogger.warn(`Removed all data for plugin ${pluginId}`, {
operation: "plugin_remove_data",
+43 -4
View File
@@ -3,20 +3,23 @@
// Unauthenticated on purpose, the same as /assets: import() cannot carry the
// bearer header, and nothing here is secret. It only ever serves files from
// the two directories a plugin ships for the browser, so a request can never
// reach the plugin's backend bundle, migrations or anything outside it.
// reach the plugin's backend bundle, migrations or anything outside it, and
// only for a plugin that is running.
import crypto from "node:crypto";
import fs from "node:fs";
import path from "node:path";
import express, { type Request, type Response } from "express";
import {
DEFAULT_BACKEND_ENTRY,
DEFAULT_FRONTEND_ENTRY,
DEFAULT_LOCALES_DIR,
type PluginManifest,
} from "@termix/plugin-sdk/manifest";
import type { LoadedPlugin } from "./loader.js";
type AssetPlugin = Pick<LoadedPlugin, "id" | "dir" | "manifest">;
type AssetPlugin = Pick<LoadedPlugin, "id" | "dir" | "manifest"> &
Partial<Pick<LoadedPlugin, "state">>;
export interface PluginFrontendInfo {
frontend: boolean;
@@ -135,9 +138,41 @@ export function resolvePluginAsset(
const file = path.resolve(root, rest);
if (!isInside(root, file) || !fileExists(file)) return null;
// The text check above cannot see a symlink, so the real paths have to
// agree too: a link inside dist/ must not reach a file outside the plugin.
let real: string;
try {
real = fs.realpathSync(file);
if (!isInside(fs.realpathSync(plugin.dir), real)) return null;
} catch {
return null;
}
if (isServerOnly(plugin, real)) return null;
return file;
}
/** The backend entry and the migrations, wherever the manifest put them. */
function isServerOnly(plugin: AssetPlugin, real: string): boolean {
const manifest = plugin.manifest as PluginManifest;
const backend = path.resolve(
plugin.dir,
manifest.backend ?? DEFAULT_BACKEND_ENTRY,
);
const candidates = [backend, `${backend}.map`];
for (const candidate of candidates) {
try {
if (fs.realpathSync(candidate) === real) return true;
} catch {
// Not there.
}
}
const segments = path
.relative(fs.realpathSync(plugin.dir), real)
.split(path.sep);
return segments.includes("migrations");
}
const CONTENT_TYPES: Record<string, string> = {
".js": "text/javascript; charset=utf-8",
".css": "text/css; charset=utf-8",
@@ -182,7 +217,7 @@ export function createPluginAssetsRouter(
* 200:
* description: The file.
* 404:
* description: Unknown plugin or file.
* description: Unknown or stopped plugin, or no such file.
*/
router.get(
/^\/([a-z][a-z0-9-]{1,39})\/(.+)$/,
@@ -190,7 +225,11 @@ export function createPluginAssetsRouter(
const params = req.params as unknown as Record<string, string>;
const pluginId = params[0];
const plugin = getPlugin(pluginId);
const file = plugin ? resolvePluginAsset(plugin, params[1]) : null;
// A disabled or failed plugin serves nothing, so its bundle cannot be
// loaded around the shell's own enabled check.
const running = !!plugin && (!plugin.state || plugin.state === "active");
const file =
plugin && running ? resolvePluginAsset(plugin, params[1]) : null;
// Loaded as a module from Electron's file:// page and the Vite dev
// server, so any origin may read it. No credentials are involved.
+1 -1
View File
@@ -6,7 +6,7 @@
*/
export interface RegistrationConflict {
kind: "service" | "registry" | "permission" | "ws";
kind: "service" | "registry" | "permission" | "ws" | "id";
pluginId: string;
/** Who already held it, when known. */
heldBy?: string;
+2 -3
View File
@@ -26,9 +26,8 @@ import type {
PluginSshKeyCredential,
} from "@termix/plugin-sdk/backend";
import ssh2 from "ssh2";
import { PluginCapabilityError } from "@termix/plugin-sdk/backend";
import type { PluginManifest } from "@termix/plugin-sdk/manifest";
import { assertCapability } from "./permissions.js";
import { assertCapability, capabilityRefused } from "./permissions.js";
import { getActor } from "./actor.js";
import type { DisposableBag } from "./disposables.js";
import { registerSecretResolver } from "../hosts/connect/secret-resolver-registry.js";
@@ -293,7 +292,7 @@ export function createPluginCredentials({
registerSecretResolver: (scheme, resolve) => {
if (!declared.includes("auth:provide")) {
throw new PluginCapabilityError(pluginId, "auth:provide");
throw capabilityRefused(pluginId, "auth:provide");
}
if (!manifest.contributes?.auth?.secretSchemes?.includes(scheme)) {
throw new Error(
+103 -14
View File
@@ -21,8 +21,7 @@ import type {
PluginShareableRole,
} from "@termix/plugin-sdk/backend";
import type { PluginManifest } from "@termix/plugin-sdk/manifest";
import { PluginCapabilityError } from "@termix/plugin-sdk/backend";
import { assertCapability } from "./permissions.js";
import { assertCapability, capabilityRefused } from "./permissions.js";
import { hostSessionStatus } from "../hosts/host-session-status.js";
import { getActor } from "./actor.js";
import { hostStatusService } from "../hosts/status/host-status-service.js";
@@ -65,8 +64,56 @@ function toSummary(host: {
};
}
/**
* Host fields that carry secret material. Stripped from anything ctx.hosts or
* ctx.ssh hands a plugin, unless the plugin holds credentials:read.
*/
export const HOST_SECRET_FIELDS = [
"password",
"key",
"keyPassword",
"privateKey",
"passphrase",
"sudoPassword",
"socks5Password",
"rdpPassword",
"vncPassword",
"telnetPassword",
"autostartPassword",
"autostartKey",
"autostartKeyPassword",
"vaultToken",
] as const;
/** A copy of a host with every secret field removed, nested ones included. */
export function redactHostSecrets<T extends Record<string, unknown>>(
host: T,
): T {
const copy: Record<string, unknown> = { ...host };
for (const field of HOST_SECRET_FIELDS) delete copy[field];
const terminalConfig = copy.terminalConfig;
if (terminalConfig && typeof terminalConfig === "object") {
const { sudoPassword: _sudo, ...rest } = terminalConfig as Record<
string,
unknown
>;
copy.terminalConfig = rest;
}
if (Array.isArray(copy.socks5ProxyChain)) {
copy.socks5ProxyChain = copy.socks5ProxyChain.map((hop) =>
hop && typeof hop === "object"
? redactHostSecrets(hop as Record<string, unknown>)
: hop,
);
}
return copy as T;
}
/** Columns that say who a host belongs to. ctx.hosts.update never moves them. */
const PROTECTED_HOST_FIELDS = new Set(["id", "userId", "syncId"]);
function toRecord(host: Record<string, unknown>): PluginHostRecord {
return {
return redactHostSecrets({
id: host.id as number,
userId: host.userId as string,
name: (host.name as string | null) ?? null,
@@ -85,7 +132,7 @@ function toRecord(host: Record<string, unknown>): PluginHostRecord {
createdAt: (host.createdAt as string | null) ?? null,
updatedAt: (host.updatedAt as string | null) ?? null,
...host,
};
});
}
function actingUser(): string {
@@ -105,12 +152,27 @@ export function createPluginHosts({ manifest, bag, audit }: Deps): PluginHosts {
const requireRead = () => assertCapability(pluginId, "hosts:read", declared);
const requireReadSync = () => {
if (!declared.includes("hosts:read")) {
throw new PluginCapabilityError(pluginId, "hosts:read");
throw capabilityRefused(pluginId, "hosts:read");
}
};
const requireWrite = () =>
assertCapability(pluginId, "hosts:write", declared);
// With an actor, a host the actor cannot reach is out of bounds. With none,
// it is the plugin's own background work (a poller, a guacd session).
const actorMayReach = async (hostId: number): Promise<boolean> => {
const actor = getActor();
if (!actor) return true;
const { PermissionManager } =
await import("../utils/permission-manager.js");
const access = await PermissionManager.getInstance().canAccessHost(
actor,
hostId,
"connect",
);
return access.hasAccess;
};
return {
list: async () => {
await requireRead();
@@ -235,13 +297,18 @@ export function createPluginHosts({ manifest, bag, audit }: Deps): PluginHosts {
throw error;
}
const userId = actingUser();
const allowed = Object.fromEntries(
Object.entries(patch as Record<string, unknown>).filter(
([field]) => !PROTECTED_HOST_FIELDS.has(field),
),
) as PluginHostUpdateInput;
const { createCurrentHostRepository } =
await import("../database/repositories/factory.js");
const updated =
await createCurrentHostRepository().updateEncryptedForUser(
userId,
hostId,
patch,
allowed,
);
await audit("hosts_update", `host ${hostId}`, {
success: updated !== null,
@@ -287,6 +354,9 @@ export function createPluginHosts({ manifest, bag, audit }: Deps): PluginHosts {
await import("../database/repositories/factory.js");
const rows =
await createCurrentHostRepository().listDecryptedByUserId(userId);
await audit("hosts_list_owned", `${rows.length} host(s)`, {
success: true,
});
return rows.map((row) =>
toRecord(row as unknown as Record<string, unknown>),
);
@@ -437,36 +507,55 @@ export function createPluginHosts({ manifest, bag, audit }: Deps): PluginHosts {
},
// Synchronous so a transport can call it from an ssh2 "ready" handler, so
// only the declaration is checked here, like ctx.http.router.
// only the declaration is checked up front, like ctx.http.router. The
// session only counts once the actor's access to the host is confirmed.
trackSession: (hostId: number) => {
if (!declared.includes("hosts:read")) {
throw new PluginCapabilityError(pluginId, "hosts:read");
throw capabilityRefused(pluginId, "hosts:read");
}
return hostSessionStatus.register(hostId);
let release: (() => void) | null = null;
let stopped = false;
void actorMayReach(hostId)
.then((allowed) => {
if (allowed && !stopped) release = hostSessionStatus.register(hostId);
})
.catch(() => {});
return () => {
stopped = true;
release?.();
release = null;
};
},
recordActivity: async (hostId, type, hostName) => {
await requireRead();
const userId = actingUser();
if (!(await actorMayReach(hostId))) return;
const { recordRecentActivity } =
await import("../services/recent-activity.js");
await recordRecentActivity(userId, { type, hostId, hostName });
},
// Synchronous and not audited, like trackSession: pollers call these on
// every sample.
// Not audited: pollers call these on every sample.
status: {
get: (hostId) => {
requireReadSync();
get: async (hostId) => {
await requireRead();
if (!(await actorMayReach(hostId))) return null;
return hostStatusService.get(hostId);
},
check: async (hostId) => {
await requireRead();
if (!(await actorMayReach(hostId))) return null;
return hostStatusService.check(hostId);
},
// Synchronous for the same reason as trackSession.
reportLogin: (hostId, outcome) => {
requireReadSync();
hostStatusService.reportLogin(hostId, outcome);
void actorMayReach(hostId)
.then((allowed) => {
if (allowed) hostStatusService.reportLogin(hostId, outcome);
})
.catch(() => {});
},
registerPort: (connectionType, resolve) => {
requireReadSync();
+45 -2
View File
@@ -88,10 +88,10 @@ export function createPluginProcess(deps: Deps): PluginProcess {
};
const run: PluginProcess["run"] = (file, args, options = {}) =>
audited("process_run", `ran ${path.basename(file)}`, async () => {
audited("process_run", `ran ${path.basename(String(file))}`, async () => {
const child = spawn(file, [...args], {
cwd: options.cwd,
env: { ...process.env, ...(options.env ?? {}) },
env: { ...baseEnvironment(), ...(options.env ?? {}) },
stdio: ["ignore", "pipe", "pipe"],
shell: false,
});
@@ -210,3 +210,46 @@ export function createPluginProcess(deps: Deps): PluginProcess {
return { run, ensureBinary };
}
/**
* What a plugin's program inherits from the server: enough to find binaries,
* a home and temp folder, the locale and the outbound proxy. Never the rest,
* which holds the JWT secret, database keys and OIDC client secrets.
*/
const INHERITED_ENV = [
"PATH",
"PATHEXT",
"HOME",
"USERPROFILE",
"APPDATA",
"LOCALAPPDATA",
"XDG_CONFIG_HOME",
"XDG_CACHE_HOME",
"TMP",
"TEMP",
"TMPDIR",
"SystemRoot",
"windir",
"ComSpec",
"LANG",
"LC_ALL",
"TZ",
"HTTP_PROXY",
"HTTPS_PROXY",
"NO_PROXY",
"http_proxy",
"https_proxy",
"no_proxy",
"SSL_CERT_FILE",
"SSL_CERT_DIR",
];
export function baseEnvironment(
env: NodeJS.ProcessEnv = process.env,
): NodeJS.ProcessEnv {
const inherited: NodeJS.ProcessEnv = {};
for (const name of INHERITED_ENV) {
if (env[name] !== undefined) inherited[name] = env[name];
}
return inherited;
}
+161 -41
View File
@@ -14,13 +14,17 @@ import type {
PluginSshHost,
} from "@termix/plugin-sdk/backend";
import type { PluginManifest } from "@termix/plugin-sdk/manifest";
import { PluginSshInteractionError } from "@termix/plugin-sdk/backend";
import {
PluginCapabilityError,
PluginSshInteractionError,
} from "@termix/plugin-sdk/backend";
import { assertCapability } from "./permissions.js";
assertCapability,
capabilityRefused,
hasCachedCapability,
hasCapability,
warmPluginGrants,
} from "./permissions.js";
import { getActor, getActorSessionId } from "./actor.js";
import type { DisposableBag } from "./disposables.js";
import { redactHostSecrets } from "./ctx-hosts.js";
import {
getSshAuthProvider,
listSshAuthProviders,
@@ -74,16 +78,16 @@ function profileOf(options?: { profile?: string }): SshConnectProfile {
}
/**
* The user a connection runs as: the request's actor, or for background work
* on a resolved host, that host's owner. A bare host id with no actor has
* nobody to resolve it for.
* The user a connection runs as: the actor, or for a host core resolved
* earlier, the user it was resolved for. A host object's own userId is plugin
* data and is never believed, or a plugin could borrow another user's jump
* hosts and stored credentials by naming them.
*/
function actingUser(host: number | PluginSshHost): string {
const actor = getActor();
function actingUser(resolvedFor?: string): string {
const actor = getActor() ?? resolvedFor;
if (actor) return actor;
if (typeof host === "object" && host.userId) return host.userId;
throw new Error(
"ctx.ssh needs an acting user: call it inside a request, ctx.asUser, or pass a resolved host",
"ctx.ssh needs an acting user: call it inside a request or ctx.asUser",
);
}
@@ -119,6 +123,47 @@ export function createPluginSsh({ manifest, bag, audit }: Deps): PluginSsh {
}
};
// Hosts core resolved, keyed by the redacted copy the plugin was given, so
// handing that copy back to connect still connects with the real secrets.
const resolvedHosts = new WeakMap<
object,
{ host: SshConnectHost; userId: string }
>();
const remember = (key: object, host: SshConnectHost, userId: string) =>
resolvedHosts.set(key, { host, userId });
const handOut = (host: SshConnectHost, userId: string): PluginSshHost => {
const redacted = redactHostSecrets(
host as unknown as Record<string, unknown>,
) as unknown as PluginSshHost;
remember(redacted, host, userId);
return redacted;
};
/** The acting user for a call about this host. */
const userFor = (host: number | PluginSshHost | undefined): string =>
actingUser(
host && typeof host === "object"
? resolvedHosts.get(host)?.userId
: undefined,
);
/**
* A host for core's pipeline. An id stays an id (core resolves it with an
* access check). An object core handed out maps back to what core resolved;
* any other object is the plugin's own, so it runs as the actor.
*/
const toConnectHost = (
host: number | PluginSshHost,
userId: string,
): number | SshConnectHost => {
if (typeof host === "number") return host;
const known = resolvedHosts.get(host);
if (known) return known.host;
return { ...(host as unknown as SshConnectHost), userId };
};
/** Checks, connects and audits one new connection. */
const connectOnce = async (
host: number | PluginSshHost,
@@ -134,10 +179,19 @@ export function createPluginSsh({ manifest, bag, audit }: Deps): PluginSsh {
throw error;
}
const userId = actingUser(host);
let userId: string;
try {
userId = userFor(host);
} catch (error) {
await audit("ssh_connect", describeHost(host), {
success: false,
errorMessage: error instanceof Error ? error.message : String(error),
});
throw error;
}
const { connectHost } = await import("../hosts/connect/connect-host.js");
try {
const connection = await connectHost(host as number | SshConnectHost, {
const connection = await connectHost(toConnectHost(host, userId), {
userId,
purpose: purposeOf(options),
profile: profileOf(options),
@@ -156,7 +210,7 @@ export function createPluginSsh({ manifest, bag, audit }: Deps): PluginSsh {
connection.client.once("close", () => open.delete(dispose));
return {
...connection,
host: connection.host as unknown as PluginSshHost,
host: handOut(connection.host as SshConnectHost, userId),
dispose,
};
} catch (error) {
@@ -188,10 +242,12 @@ export function createPluginSsh({ manifest, bag, audit }: Deps): PluginSsh {
await checkSsh(true);
const { resolveConnectHost } =
await import("../hosts/connect/connect-host.js");
const userId = userFor(host);
const resolved = await resolveConnectHost(
host as number | SshConnectHost,
actingUser(host),
toConnectHost(host, userId),
userId,
);
remember(resolved, resolved, userId);
const key = poolKey(options.pool, resolved as PluginSshHost);
poolKeys.set(key, resolved.id);
const { withConnection } =
@@ -206,7 +262,7 @@ export function createPluginSsh({ manifest, bag, audit }: Deps): PluginSsh {
jumpChain: async (jumpHosts, chainOptions) => {
await checkSsh(true);
const userId = actingUser(chainOptions?.forHost ?? 0);
const userId = userFor(chainOptions?.forHost);
const { createJumpHostChain } =
await import("../hosts/jump-host-chain.js");
const client = await createJumpHostChain(jumpHosts, userId);
@@ -227,12 +283,16 @@ export function createPluginSsh({ manifest, bag, audit }: Deps): PluginSsh {
client.once("close", () => open.delete(dispose));
// A jump chain ends at a forwarding client, not a single resolved SSH
// host: report the caller's own host if it gave one, else the last hop.
const host: PluginSshHost = chainOptions?.forHost ?? {
id: jumpHosts[jumpHosts.length - 1]?.hostId ?? 0,
ip: "",
port: 22,
username: "",
};
const host: PluginSshHost = chainOptions?.forHost
? (redactHostSecrets(
chainOptions.forHost as unknown as Record<string, unknown>,
) as unknown as PluginSshHost)
: {
id: jumpHosts[jumpHosts.length - 1]?.hostId ?? 0,
ip: "",
port: 22,
username: "",
};
return { client: client as never, jumpClient: null, host, dispose };
},
@@ -253,7 +313,7 @@ export function createPluginSsh({ manifest, bag, audit }: Deps): PluginSsh {
resolveHost: async (hostId, options) => {
await checkSsh(true);
const userId = actingUser(hostId);
const userId = actingUser();
const { resolveHostById, resolveHostBySyncId } =
await import("../hosts/host-resolver.js");
const resolved = options?.syncId
@@ -263,15 +323,30 @@ export function createPluginSsh({ manifest, bag, audit }: Deps): PluginSsh {
success: !!resolved,
errorMessage: resolved ? undefined : "Host not found",
});
return (resolved as unknown as PluginSshHost | null) ?? null;
if (!resolved) return null;
// The secrets only go to a plugin that declared it reads them.
if (await hasCapability(pluginId, "credentials:read", declared)) {
await audit("credentials_read", describeHost(hostId), {
success: true,
});
const full = resolved as unknown as SshConnectHost;
remember(full, full, userId);
return full as unknown as PluginSshHost;
}
return handOut(resolved as unknown as SshConnectHost, userId);
},
// The config it returns carries the password and private key, so it
// needs credentials:read on top of the connect pair.
prepare: async (host, options) => {
await checkSsh(true);
await assertCapability(pluginId, "credentials:read", declared);
const userId = userFor(host);
const target = toConnectHost(host, userId) as SshConnectHost;
const { buildConnectConfig } =
await import("../hosts/connect/build-connect-config.js");
const built = await buildConnectConfig(host as SshConnectHost, {
userId: actingUser(host),
const built = await buildConnectConfig(target, {
userId,
purpose: purposeOf(options),
profile: profileOf(options),
client: options.client as never,
@@ -286,7 +361,6 @@ export function createPluginSsh({ manifest, bag, audit }: Deps): PluginSsh {
built.outcome.status === "ready" ? undefined : built.outcome.message,
});
const provider = built.provider;
const target = host as SshConnectHost;
return {
config: built.config,
outcome: built.outcome,
@@ -302,14 +376,25 @@ export function createPluginSsh({ manifest, bag, audit }: Deps): PluginSsh {
},
openTransport: async (host, config, transportOptions) => {
await checkSsh(false);
await checkSsh(true);
const target = toConnectHost(host, userFor(host)) as SshConnectHost;
const { openSshTransport } =
await import("../hosts/connect/transport.js");
const opened = await openSshTransport(
host as SshConnectHost,
config as MutableConnectConfig,
transportOptions,
);
let opened: Awaited<ReturnType<typeof openSshTransport>>;
try {
opened = await openSshTransport(
target,
config as MutableConnectConfig,
transportOptions,
);
} catch (error) {
await audit("ssh_open_transport", describeHost(host), {
success: false,
errorMessage: error instanceof Error ? error.message : String(error),
});
throw error;
}
await audit("ssh_open_transport", describeHost(host), { success: true });
if (opened.jumpClient) {
const jumpClient = opened.jumpClient;
const dispose = () => {
@@ -324,8 +409,22 @@ export function createPluginSsh({ manifest, bag, audit }: Deps): PluginSsh {
startInteraction: async (interaction, request) => {
await checkSsh(true);
const userId = actingUser(request.hostId);
const userId = actingUser();
ensureCoreSshAuthProviders();
const { PermissionManager } =
await import("../utils/permission-manager.js");
const access = await PermissionManager.getInstance().canAccessHost(
userId,
request.hostId,
"connect",
);
if (!access.hasAccess) {
await audit("ssh_start_interaction", describeHost(request.hostId), {
success: false,
errorMessage: "Host not found",
});
throw new PluginSshInteractionError("Host not found");
}
const { createCurrentHostResolutionRepository } =
await import("../database/repositories/factory.js");
const host = await createCurrentHostResolutionRepository().findHostById(
@@ -367,7 +466,7 @@ export function createPluginSsh({ manifest, bag, audit }: Deps): PluginSsh {
cancelInteraction: async (interaction, request) => {
await checkSsh(false);
const userId = actingUser(request.hostId ?? 0);
const userId = actingUser();
ensureCoreSshAuthProviders();
for (const provider of listSshAuthProviders()) {
if (provider.interaction !== interaction) continue;
@@ -467,7 +566,7 @@ export function createPluginAuth({ manifest, bag, audit }: Deps): PluginAuth {
field: string,
) => {
if (!declared.includes("auth:provide")) {
throw new PluginCapabilityError(pluginId, "auth:provide");
throw capabilityRefused(pluginId, "auth:provide");
}
if (!list?.includes(id)) {
throw new Error(
@@ -477,6 +576,10 @@ export function createPluginAuth({ manifest, bag, audit }: Deps): PluginAuth {
};
const granted = () => assertCapability(pluginId, "auth:provide", declared);
// ssh2 calls some hooks synchronously, so those answer from the grant
// cache and do nothing until the grant is confirmed.
const grantedNow = () =>
hasCachedCapability(pluginId, "auth:provide", declared);
const record = (action: string, id: string) =>
void audit(action, `${pluginId} registered ${id}`, { success: true });
@@ -499,8 +602,22 @@ export function createPluginAuth({ manifest, bag, audit }: Deps): PluginAuth {
if (provider.onBanner) {
const onBanner = provider.onBanner;
wrapped.onBanner = (banner, host, env) =>
onBanner(banner, host as PluginSshHost, env as never) as never;
grantedNow()
? (onBanner(banner, host as PluginSshHost, env as never) as never)
: (undefined as never);
}
if (provider.onAuthFailed) {
const onAuthFailed = provider.onAuthFailed;
wrapped.onAuthFailed = (host, env, context) =>
grantedNow()
? (onAuthFailed(
host as PluginSshHost,
env as never,
context as never,
) as never)
: undefined;
}
warmPluginGrants(pluginId);
ensureCoreSshAuthProviders();
const disposers = [registerSshAuthProvider(wrapped)];
if (provider.onKeyboardInteractive) {
@@ -510,7 +627,7 @@ export function createPluginAuth({ manifest, bag, audit }: Deps): PluginAuth {
id: `${pluginId}:${provider.type}`,
pluginId,
detect: (round, host) =>
host.authType === provider.type
host.authType === provider.type && grantedNow()
? (detect(round, host as PluginSshHost) as never)
: null,
}),
@@ -529,8 +646,9 @@ export function createPluginAuth({ manifest, bag, audit }: Deps): PluginAuth {
handler.id,
"keyboardInteractive",
);
// Synchronous hooks inside ssh2's callback, so only the declaration is
// checked, like ctx.http.router. Deactivate removes the handler.
// Synchronous hooks inside ssh2's callback, so they answer from the
// grant cache. Deactivate removes the handler.
warmPluginGrants(pluginId);
const settingsFor = (host: SshConnectHost): Record<string, unknown> =>
host.pluginSettings?.[pluginId] ?? {};
ensureCoreSshAuthProviders();
@@ -538,6 +656,7 @@ export function createPluginAuth({ manifest, bag, audit }: Deps): PluginAuth {
id: handler.id,
pluginId,
detect: (round, host) => {
if (!grantedNow()) return null;
const detected = handler.detect(
round,
host as PluginSshHost,
@@ -551,6 +670,7 @@ export function createPluginAuth({ manifest, bag, audit }: Deps): PluginAuth {
},
autoAnswerPasswords: handler.autoAnswerPasswords
? (host) =>
grantedNow() &&
handler.autoAnswerPasswords!(
host as PluginSshHost,
settingsFor(host),
+51 -36
View File
@@ -27,7 +27,11 @@ import * as serviceRegistry from "./service-registry.js";
import type { ServiceRegistration } from "./service-registry.js";
import * as secretRegistry from "./secret-registry.js";
import type { SecretRegistration } from "./secret-registry.js";
import { assertCapability, hasCapability } from "./permissions.js";
import {
assertCapability,
capabilityRefused,
hasCapability,
} from "./permissions.js";
import { getActor, runAsActor } from "./actor.js";
import { DisposableBag } from "./disposables.js";
import {
@@ -40,7 +44,6 @@ import { resolvePermission } from "./rbac.js";
import * as pluginSettings from "./settings.js";
import type { PluginManifest } from "@termix/plugin-sdk/manifest";
import {
PluginCapabilityError,
type PluginContext,
type PluginModule,
type PluginOpenIsolatedWindowRequest,
@@ -110,8 +113,9 @@ interface AuditOptions {
/**
* Wraps a privileged function in the capability check and the audit line.
*
* The check runs before the call and the audit after, so a denied call is
* still recorded: "tried and was refused" is exactly the thing worth seeing.
* The check runs before the call and the audit after. A denied call is still
* recorded, by assertCapability: "tried and was refused" is exactly the thing
* worth seeing.
*/
function guarded<Args extends unknown[], Result>(
manifest: PluginManifest,
@@ -120,17 +124,13 @@ function guarded<Args extends unknown[], Result>(
options: AuditOptions,
): (...args: Args) => Promise<Result> {
return async (...args: Args): Promise<Result> => {
const pluginId = manifest.id;
try {
await assertCapability(pluginId, capability, manifest.capabilities);
} catch (error) {
await writeAudit(manifest, options, {
success: false,
errorMessage: error instanceof Error ? error.message : String(error),
});
throw error;
}
// A refusal is audited inside assertCapability, under this action.
await assertCapability(
manifest.id,
capability,
manifest.capabilities,
options.action,
);
try {
const result = await fn(...args);
@@ -191,6 +191,26 @@ export function createPluginContext(
await import("../database/repositories/factory.js");
return createCurrentPluginSettingsRepository();
};
// A user named by the plugin instead of the ambient actor. Honoured, like
// ctx.asUser, and audited the same way.
const namedUser = (
userId: string | undefined,
via: string,
): string | undefined => {
if (!userId) return getActor();
if (userId !== getActor()) {
void writeAudit(
manifest,
{
action: "as_user",
details: () => `${pluginId} acted as ${userId} through ${via}`,
},
{ success: true },
);
}
return userId;
};
const secretOwner = (): string => {
const actor = getActor();
if (!actor) {
@@ -384,19 +404,12 @@ export function createPluginContext(
);
const capabilitiesRequire = async (capability: string) => {
try {
await assertCapability(pluginId, capability, declared);
} catch (error) {
await writeAudit(
manifest,
{ action: "capability_require", details: () => capability },
{
success: false,
errorMessage: error instanceof Error ? error.message : String(error),
},
);
throw error;
}
await assertCapability(
pluginId,
capability,
declared,
"capability_require",
);
await writeAudit(
manifest,
{ action: "capability_require", details: () => capability },
@@ -551,10 +564,11 @@ export function createPluginContext(
service,
pluginId,
{
// A caller-supplied userId is only honoured when it matches the
// actor; otherwise the ambient actor wins. A plugin cannot widen
// its reach by naming someone else here.
resolveUserId: () => options?.userId ?? getActor(),
// Naming a user other than the actor is the same thing as
// ctx.asUser, so it gets the same audit line.
resolveUserId: () =>
namedUser(options?.userId, `service ${service}`),
nameUser: (userId) => namedUser(userId, `service ${service}`),
hasPermission: checkPermission,
audit: (entry) => writeServiceAudit(entry),
},
@@ -667,7 +681,8 @@ export function createPluginContext(
getShared: (providerPluginId, key, options) =>
secretRegistry.readSharedSecret(manifest, providerPluginId, key, {
resolveUserId: () => options?.userId ?? getActor(),
resolveUserId: () =>
namedUser(options?.userId, `shared secret ${providerPluginId}`),
hasPermission: checkPermission,
audit: (entry) => writeSecretAudit(entry),
}),
@@ -679,7 +694,7 @@ export function createPluginContext(
// and router() has to be synchronous so a plugin can register routes
// inline in activate. http.ts re-checks on every request.
if (!declared.includes("network:serve")) {
throw new PluginCapabilityError(pluginId, "network:serve");
throw capabilityRefused(pluginId, "network:serve");
}
const router = createPluginRouter({
@@ -703,7 +718,7 @@ export function createPluginContext(
ws: {
route: (path, wsHandler, options) => {
if (!declared.includes("network:serve")) {
throw new PluginCapabilityError(pluginId, "network:serve");
throw capabilityRefused(pluginId, "network:serve");
}
const dispose = registerPluginWsRoute(
pluginId,
@@ -717,7 +732,7 @@ export function createPluginContext(
upgrade: (path, upgradeHandler, options) => {
if (!declared.includes("network:serve")) {
throw new PluginCapabilityError(pluginId, "network:serve");
throw capabilityRefused(pluginId, "network:serve");
}
const dispose = registerPluginWsUpgrade(
pluginId,
+90 -17
View File
@@ -12,8 +12,8 @@ import { pluginLogger } from "../utils/logger.js";
import { resolveDatabaseDialect } from "../database/db/dialect.js";
import type { DatabaseDialect } from "../database/db/dialect.js";
import type { PluginTableDefinition } from "@termix/plugin-sdk/db";
import { prefixedTableName } from "@termix/plugin-sdk/db";
import { buildTable, dropTableSql } from "./table-builder.js";
import { prefixedTableName, tablePrefix } from "@termix/plugin-sdk/db";
import { buildTable } from "./table-builder.js";
import {
applyPluginMigrations,
readMigrations,
@@ -88,10 +88,9 @@ async function execute(statements: string[]): Promise<void> {
/**
* Builds the runner that applies migrations against the live database.
*
* SQLite gets a transaction; the client-server engines do not, because MySQL
* commits implicitly on DDL so a "transaction" there would only be a promise
* the engine does not keep. A half-applied migration is recorded statement by
* statement either way, so a retry resumes rather than repeating.
* No transaction on any engine: MySQL commits implicitly on DDL, and each
* migration is recorded once it has run, so a retry after a failure starts
* at the migration that failed.
*/
async function createMigrationRunner(
dialect: DatabaseDialect = resolveDatabaseDialect(),
@@ -126,37 +125,59 @@ async function createMigrationRunner(
export async function migratePlugin(
pluginId: string,
pluginDir: string,
dialect: DatabaseDialect = resolveDatabaseDialect(),
options: { bundled?: boolean; dialect?: DatabaseDialect } = {},
): Promise<string[]> {
const dialect = options.dialect ?? resolveDatabaseDialect();
if (readMigrations(pluginDir, dialect).length === 0) return [];
const runner = await createMigrationRunner(dialect);
return applyPluginMigrations(pluginId, pluginDir, runner);
return applyPluginMigrations(pluginId, pluginDir, runner, {
bundled: options.bundled,
});
}
/**
* Removes everything a plugin owns.
*
* Tables go in reverse registration order so a table referenced by a later one
* is dropped last. Disabling a plugin never calls this: data outlives being
* turned off, and only an explicit uninstall throws it away.
* Tables are found in the database's own catalog by the plugin's prefix, not
* from what it registered this boot, so a plugin that never activated since
* the restart still loses its tables. A longer prefix that belongs to another
* known plugin is left alone. Disabling a plugin never calls this: data
* outlives being turned off, and only an explicit uninstall throws it away.
* Role permissions stay, so a role keeps them if the plugin comes back.
*/
export async function removePluginData(
pluginId: string,
dialect: DatabaseDialect = resolveDatabaseDialect(),
options: { knownPluginIds?: string[]; dialect?: DatabaseDialect } = {},
): Promise<{ tables: string[]; kvKeys: number; migrations: number }> {
const definitions = listTables(pluginId);
const dropped: string[] = [];
const dialect = options.dialect ?? resolveDatabaseDialect();
const tables = ownedTableNames(
pluginId,
await listDatabaseTables(dialect),
options.knownPluginIds ?? [],
);
for (const definition of [...definitions].reverse()) {
await execute([dropTableSql(dialect, pluginId, definition)]);
dropped.push(prefixedTableName(pluginId, definition.name));
// Registered order reversed first, so a referencing table goes before the
// one it points at; anything else after.
const registeredOrder = listTables(pluginId)
.map((definition) => prefixedTableName(pluginId, definition.name))
.reverse();
const ordered = [
...registeredOrder.filter((table) => tables.includes(table)),
...tables.filter((table) => !registeredOrder.includes(table)),
];
const dropped: string[] = [];
for (const table of ordered) {
await execute([dropTableByName(dialect, table)]);
dropped.push(table);
}
const {
createCurrentPluginStorageRepository,
createCurrentPluginMigrationRepository,
createCurrentPluginPermissionGrantRepository,
createCurrentPluginSettingsRepository,
} = await import("../database/repositories/factory.js");
const kvKeys =
@@ -164,6 +185,8 @@ export async function removePluginData(
const migrations =
await createCurrentPluginMigrationRepository().deleteByPlugin(pluginId);
await createCurrentPluginPermissionGrantRepository().deleteByPlugin(pluginId);
// Settings and the plugin's encrypted secrets go with it.
await createCurrentPluginSettingsRepository().deleteByPlugin(pluginId);
forgetTables(pluginId);
@@ -174,3 +197,53 @@ export async function removePluginData(
return { tables: dropped, kvKeys, migrations };
}
/**
* The tables in `all` that belong to this plugin: its prefix, minus any that
* sit under a longer prefix another plugin owns.
*/
export function ownedTableNames(
pluginId: string,
all: string[],
knownPluginIds: string[],
): string[] {
const prefix = tablePrefix(pluginId);
const longer = knownPluginIds
.filter((id) => id !== pluginId)
.map((id) => tablePrefix(id))
.filter((other) => other.startsWith(prefix));
return all.filter(
(table) =>
table.startsWith(prefix) &&
!longer.some((other) => table.startsWith(other)),
);
}
function dropTableByName(dialect: DatabaseDialect, table: string): string {
const quoted = dialect === "mysql" ? "`" + table + "`" : `"${table}"`;
return `DROP TABLE IF EXISTS ${quoted};`;
}
async function listDatabaseTables(dialect: DatabaseDialect): Promise<string[]> {
const { getDb } = await import("../database/db/index.js");
const db = getDb() as unknown as {
all?: (query: unknown) => Promise<Array<Record<string, unknown>>>;
execute?: (query: unknown) => Promise<unknown>;
};
if (dialect === "sqlite") {
const rows = await db.all!(
sql`SELECT name FROM sqlite_master WHERE type = 'table'`,
);
return rows.map((row) => String(row.name));
}
const query =
dialect === "postgres"
? sql`SELECT table_name AS name FROM information_schema.tables WHERE table_schema = current_schema()`
: sql`SELECT table_name AS name FROM information_schema.tables WHERE table_schema = DATABASE()`;
const result = await db.execute!(query);
// node-postgres answers { rows }, mysql2 answers [rows, fields].
const rows = (
Array.isArray(result) ? result[0] : (result as { rows?: unknown[] }).rows
) as Array<Record<string, unknown>>;
return rows.map((row) => String(row.name ?? row.NAME));
}
+50 -1
View File
@@ -38,11 +38,19 @@ const DEFAULT_BODY_LIMIT = "2mb";
/** Routers by plugin id, read by the dispatcher in plugin-api-routes.ts. */
const routers = new Map<string, Router>();
/** Declared public paths by plugin id, for the admin plugin details. */
const publicRoutes = new Map<string, string[]>();
export function getPluginPublicHttpRoutes(pluginId: string): string[] {
return [...(publicRoutes.get(pluginId) ?? [])];
}
export function getPluginRouter(pluginId: string): Router | undefined {
return routers.get(pluginId);
}
export function unregisterPluginHttp(pluginId: string): void {
publicRoutes.delete(pluginId);
if (!routers.delete(pluginId)) return;
pluginLogger.info(`Unmounted /plugin-api/${pluginId}`, {
operation: "plugin_http_unmount",
@@ -63,10 +71,27 @@ export function setPluginEnabledCheck(check: IsEnabled): void {
isPluginEnabled = check;
}
/**
* Whether a plugin with this id is installed at all, running or not. A
* disabled plugin has no router or socket routes left, so this is what tells
* "disabled" (503) apart from "no such plugin" (404).
*/
let isInstalled: IsEnabled = () => false;
export function setPluginInstalledCheck(check: IsEnabled): void {
isInstalled = check;
}
export function isPluginInstalled(pluginId: string): boolean {
return isInstalled(pluginId);
}
/** Test seam, so one test's stub does not leak into the next. */
export function resetPluginHttp(): void {
routers.clear();
publicRoutes.clear();
isPluginEnabled = () => true;
isInstalled = () => false;
}
function normalizePublicPath(path: string): string {
@@ -130,6 +155,7 @@ export function createPluginRouter({
normalizePublicPath(path),
);
const publicPaths = declaredPublic.map((path) => compilePublicPath(path));
publicRoutes.set(pluginId, declaredPublic);
if (declaredPublic.length > 0) {
// Audited once at registration: the set of unauthenticated routes a plugin
@@ -225,7 +251,20 @@ export function createPluginRouter({
_next: NextFunction,
): void => {
void _next;
reportError(error);
const status = clientErrorStatus(error);
const authenticated = !!(req as Request & { userId?: string }).userId;
// A bad body or a 4xx is the caller's fault, and an anonymous caller
// must never be able to spend the plugin's error budget: five junk
// requests to a public route would otherwise switch the plugin off.
if (status === null && authenticated) reportError(error);
if (status !== null) {
if (!res.headersSent) {
res.status(status).json({ error: "Invalid request" });
} else {
res.end();
}
return;
}
pluginLogger.error(
`Plugin ${pluginId} route ${req.method} ${req.path} failed`,
error instanceof Error ? error : new Error(String(error)),
@@ -248,6 +287,16 @@ export function createPluginRouter({
return inner;
}
/** The status of an error that is the caller's fault, or null. */
function clientErrorStatus(error: unknown): number | null {
const status =
(error as { status?: unknown; statusCode?: unknown } | null)?.status ??
(error as { statusCode?: unknown } | null)?.statusCode;
return typeof status === "number" && status >= 400 && status < 500
? status
: null;
}
async function writePublicRouteAudit(
manifest: PluginManifest,
paths: string[],
+31 -6
View File
@@ -10,7 +10,11 @@
*/
import { pluginLogger } from "../utils/logger.js";
import { setPluginEnabledCheck, unregisterPluginHttp } from "./http.js";
import {
setPluginEnabledCheck,
setPluginInstalledCheck,
unregisterPluginHttp,
} from "./http.js";
import { PluginLoader, type LoadedPlugin } from "./loader.js";
import type { PluginPermissionContribution } from "./manifest.js";
import { invalidatePluginPermissionCache } from "./permissions.js";
@@ -29,6 +33,7 @@ export function getPluginRuntime(): { loader: PluginLoader } {
setPluginEnabledCheck(
(pluginId) => loader?.get(pluginId)?.state === "active",
);
setPluginInstalledCheck((pluginId) => !!loader?.get(pluginId));
// Lets a host whose auth type belongs to a disabled plugin name it.
setSshAuthTypeOwnerSource(() =>
(loader?.list() ?? []).flatMap((plugin) =>
@@ -256,6 +261,24 @@ async function registerPluginPermissions(plugin: LoadedPlugin): Promise<void> {
].filter((id) => id !== plugin.id),
);
// The group is the plugin id, so a plugin named after a core group would
// register that group's permissions. The manifest validator refuses the
// id too; this holds for a manifest that skipped it.
if (coreGroups.has(plugin.id)) {
recordConflict({
kind: "permission",
pluginId: plugin.id,
heldBy: "core",
name: plugin.id,
});
pluginLogger.error(
`Ignoring every ${plugin.id} permission: its id is a core permission group`,
undefined,
{ operation: "plugin_permissions" },
);
return;
}
const accepted = declared.filter((permission) => {
const head = permission.name.split(".")[0];
if (coreGroups.has(head) || otherPluginIds.has(head)) {
@@ -379,16 +402,18 @@ async function applyRoleDefaults(
return !coveredBy(current as string[], permission);
});
if (missing.length > 0) {
await roleRepository.updateRole(role.id, {
permissions: JSON.stringify([...(current as string[]), ...missing]),
});
}
// Only once the role really has them, so a failed update is retried
// on the next boot instead of being marked done.
for (const permission of permissions) {
recorded.push({ roleName, permission });
}
if (missing.length === 0) continue;
await roleRepository.updateRole(role.id, {
permissions: JSON.stringify([...(current as string[]), ...missing]),
});
for (const memberId of await roleRepository.listRoleUserIds(role.id)) {
PermissionManager.getInstance().invalidateUserPermissionCache(memberId);
}
+62 -5
View File
@@ -33,6 +33,8 @@ import {
} from "./ctx.js";
import { resolveRequirements } from "./service-registry.js";
import { resolveSecretRequirements } from "./secret-registry.js";
import { recordConflict } from "./conflicts.js";
import { tablePrefix } from "@termix/plugin-sdk/db";
export type PluginState =
| "loaded"
@@ -119,6 +121,27 @@ export class PluginLoader {
`Plugin ${manifest.id} backend entry ${entry} is missing`,
);
}
// A symlink can point anywhere, so the real path has to stay inside too.
if (!isRealPathInside(dir, entry)) {
throw new Error(
`Plugin ${manifest.id} backend entry resolves outside the plugin directory`,
);
}
const overlap = [...this.plugins.values()].find((other) =>
prefixesOverlap(other.id, manifest.id),
);
if (overlap) {
recordConflict({
kind: "id",
pluginId: manifest.id,
heldBy: overlap.id,
name: tablePrefix(manifest.id),
});
throw new Error(
`Plugin id "${manifest.id}" overlaps "${overlap.id}": their table names would share the prefix ${tablePrefix(overlap.id)}`,
);
}
const plugin: LoadedPlugin = {
id: manifest.id,
@@ -144,12 +167,15 @@ export class PluginLoader {
* dropping a folder with a bundled plugin's id into the data directory is visibly
* refused instead of quietly ignored.
*
* The check is on the parsed manifest id, not the directory name, because a
* directory can be named anything.
* The check is on the directory name, which load() then requires to equal
* the manifest id, and it covers bundled folders that failed to load too.
*/
async loadAll(): Promise<LoadedPlugin[]> {
this.plugins.clear();
const loaded: LoadedPlugin[] = [];
// Every bundled folder name, loaded or not: a bundled plugin that fails
// to load must not leave its id free for a user plugin to take.
const bundledIds = new Set<string>();
const roots: Array<{ root: string; source: PluginSource }> = [
{ root: getBundledPluginsDir(), source: "bundled" },
@@ -160,14 +186,18 @@ export class PluginLoader {
if (!fs.existsSync(root)) continue;
const entries = await fs.promises.readdir(root, { withFileTypes: true });
if (source === "bundled") {
for (const entry of entries) {
if (entry.isDirectory()) bundledIds.add(entry.name);
}
}
for (const entry of entries) {
if (!entry.isDirectory()) continue;
const dir = path.join(root, entry.name);
try {
const existing = this.plugins.get(entry.name);
if (existing && source === "user") {
if (source === "user" && bundledIds.has(entry.name)) {
throw new Error(
`a bundled plugin already uses the id "${entry.name}"; a plugin in the data directory cannot replace it`,
);
@@ -343,7 +373,9 @@ export class PluginLoader {
// than leave it half-running against a schema that is not there. A
// throw here lands in the catch below, which fails this plugin only.
const { migratePlugin } = await import("./data.js");
const applied = await migratePlugin(plugin.id, plugin.dir);
const applied = await migratePlugin(plugin.id, plugin.dir, {
bundled: plugin.source === "bundled",
});
// A new table may be where core data is waiting to move.
if (applied.length > 0) {
const { runPluginDataMoves } =
@@ -577,3 +609,28 @@ function pathToFileUrlString(filePath: string): string {
const resolved = path.resolve(filePath).replace(/\\/g, "/");
return `file://${resolved.startsWith("/") ? "" : "/"}${resolved}`;
}
/**
* True when one id's table prefix starts with the other's: "foo" owns
* p_foo_*, which already covers every table "foo-bar" (p_foo_bar_*) creates.
*/
export function prefixesOverlap(a: string, b: string): boolean {
if (a === b) return false;
const left = tablePrefix(a);
const right = tablePrefix(b);
return left.startsWith(right) || right.startsWith(left);
}
/** Whether `target` really lives under `root`, following symlinks. */
export function isRealPathInside(root: string, target: string): boolean {
try {
const realRoot = fs.realpathSync(root);
const relative = path.relative(realRoot, fs.realpathSync(target));
return (
relative === "" ||
(!relative.startsWith("..") && !path.isAbsolute(relative))
);
} catch {
return false;
}
}
+45 -31
View File
@@ -18,8 +18,11 @@ import path from "node:path";
import { sql } from "drizzle-orm";
import { pluginLogger } from "../utils/logger.js";
import type { DatabaseDialect } from "../database/db/dialect.js";
import { tablePrefix, LEGACY_TABLE_OWNERS } from "@termix/plugin-sdk/db";
import { splitStatements } from "@termix/plugin-sdk/ddl";
import { LEGACY_TABLE_OWNERS } from "@termix/plugin-sdk/db";
import {
findUnownedTableWrites,
splitStatements,
} from "@termix/plugin-sdk/ddl";
// The splitter is shared with createTestDb in the SDK, so tests apply a
// migration exactly the way this runner does.
@@ -68,6 +71,15 @@ export function readMigrations(
): PluginMigration[] {
const dir = migrationsDir(pluginDir, dialect);
if (!fs.existsSync(dir)) return [];
// A symlinked folder or file could feed in SQL from anywhere on disk.
const realRoot = fs.realpathSync(pluginDir);
const inside = (target: string) => {
const relative = path.relative(realRoot, fs.realpathSync(target));
return !relative.startsWith("..") && !path.isAbsolute(relative);
};
if (!inside(dir)) {
throw new Error("The migrations folder resolves outside the plugin");
}
const migrations: PluginMigration[] = [];
for (const file of fs.readdirSync(dir).sort()) {
@@ -80,6 +92,9 @@ export function readMigrations(
}
continue;
}
if (!inside(path.join(dir, file))) {
throw new Error(`Migration "${file}" resolves outside the plugin`);
}
const contents = fs.readFileSync(path.join(dir, file), "utf8");
migrations.push({
id: file.replace(/\.sql$/, ""),
@@ -95,41 +110,39 @@ export function readMigrations(
}
/**
* Tables a migration may create or alter.
* Checks that every table a migration writes to belongs to this plugin.
*
* A plugin owns the p_<id>_ namespace and nothing else. Enforced here as well
* as in the CLI, because a plugin installed from a tarball never ran the CLI.
*/
const CREATE_OR_ALTER =
/\b(?: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 every table a migration touches belongs to this plugin.
*
* A legacy table it is allowed to adopt is permitted too, because adoption
* renames it into the plugin's namespace in the same migration.
* A legacy core table is allowed only for the bundled plugin that adopts it:
* a user plugin with the same id gets no exemption.
*/
export function assertOwnedTables(
pluginId: string,
statements: string[],
sql: string,
options: { bundled?: boolean } = {},
): void {
const prefix = tablePrefix(pluginId);
for (const statement of statements) {
CREATE_OR_ALTER.lastIndex = 0;
let match: RegExpExecArray | null;
while ((match = CREATE_OR_ALTER.exec(statement)) !== null) {
const table = match[2];
if (table.startsWith(prefix)) continue;
if (LEGACY_TABLE_OWNERS[table] === pluginId) continue;
const owner = LEGACY_TABLE_OWNERS[table];
throw new Error(
owner
? `Plugin "${pluginId}" may not touch "${table}": it belongs to the "${owner}" plugin.`
: `Plugin "${pluginId}" may only create or alter tables prefixed "${prefix}", not "${table}".`,
);
}
const legacy = new Set(
options.bundled
? Object.entries(LEGACY_TABLE_OWNERS)
.filter(([, owner]) => owner === pluginId)
.map(([table]) => table)
: [],
);
const problems = findUnownedTableWrites(pluginId, sql, legacy).map(
(problem) => {
// Name the owner when the table is another plugin's legacy table.
const table = /writes to "([^"]+)"/.exec(problem)?.[1];
const owner = table ? LEGACY_TABLE_OWNERS[table] : undefined;
return owner && owner !== pluginId
? `may not touch "${table}": it belongs to the "${owner}" plugin`
: problem;
},
);
if (problems.length > 0) {
throw new Error(
`Plugin "${pluginId}" migration refused: ${problems.join("; ")}`,
);
}
}
@@ -158,6 +171,7 @@ export async function applyPluginMigrations(
pluginId: string,
pluginDir: string,
runner: MigrationRunner,
options: { bundled?: boolean } = {},
): Promise<string[]> {
const migrations = readMigrations(pluginDir, runner.dialect);
if (migrations.length === 0) return [];
@@ -185,8 +199,8 @@ export async function applyPluginMigrations(
const run = async () => {
for (const migration of pending) {
assertOwnedTables(pluginId, migration.sql, options);
const statements = splitStatements(migration.sql);
assertOwnedTables(pluginId, statements);
await runner.execute(statements);
await runner.record(pluginId, migration);
}
+75 -2
View File
@@ -10,10 +10,14 @@
*
* The cache is invalidated explicitly on every grant change rather than by
* TTL: a revoke that takes effect a minute later is not a revoke.
*
* Every refusal is audited here, so a guard added anywhere in the runtime
* leaves a trail without each call site remembering to write one.
*/
import { createCurrentPluginPermissionGrantRepository } from "../database/repositories/factory.js";
import { PluginCapabilityError } from "@termix/plugin-sdk/backend";
import { getActor } from "./actor.js";
const cache = new Map<string, Set<string>>();
@@ -43,12 +47,81 @@ export async function hasCapability(
return (await loadGrants(pluginId)).has(capability);
}
/**
* For hooks ssh2 calls synchronously. Answers from the cache only and fails
* closed when the grants are not loaded yet, starting the load for next time.
*/
export function hasCachedCapability(
pluginId: string,
capability: string,
declared: readonly string[],
): boolean {
if (!declared.includes(capability)) return false;
const cached = cache.get(pluginId);
if (!cached) {
void loadGrants(pluginId).catch(() => {});
return false;
}
return cached.has(capability);
}
/** Loads a plugin's grants ahead of a synchronous hasCachedCapability. */
export function warmPluginGrants(pluginId: string): void {
void loadGrants(pluginId).catch(() => {});
}
/**
* Throws PluginCapabilityError unless granted, after writing the refusal's
* audit line. `action` names the call ("kv_set"), so the line says what was
* refused and not only which capability was missing.
*/
export async function assertCapability(
pluginId: string,
capability: string,
declared: readonly string[],
action = "capability_refused",
): Promise<void> {
if (!(await hasCapability(pluginId, capability, declared))) {
throw new PluginCapabilityError(pluginId, capability);
if (await hasCapability(pluginId, capability, declared)) return;
const error = new PluginCapabilityError(pluginId, capability);
await auditRefusal(pluginId, capability, action, error);
throw error;
}
/**
* The error for a capability a sync call never declared, with its audit
* line written in the background.
*/
export function capabilityRefused(
pluginId: string,
capability: string,
action = "capability_refused",
): PluginCapabilityError {
const error = new PluginCapabilityError(pluginId, capability);
void auditRefusal(pluginId, capability, action, error);
return error;
}
async function auditRefusal(
pluginId: string,
capability: string,
action: string,
error: Error,
): Promise<void> {
try {
const { logAudit } = await import("../utils/audit-logger.js");
await logAudit({
// Attribution comes from the runtime, never from the plugin.
userId: getActor() ?? "system",
username: `plugin:${pluginId}`,
action: `plugin_${action}`,
resourceType: "plugin",
resourceId: pluginId,
resourceName: pluginId,
details: `refused: ${capability}`,
success: false,
errorMessage: error.message,
});
} catch {
// Auditing must never change what the caller sees.
}
}
+9
View File
@@ -41,6 +41,15 @@ export function resolvePermission(
if (!head) return permission;
if (head === manifest.id) return permission;
// A name this plugin declares is its own, even if another plugin's id
// happens to match its first segment.
if (
(manifest.contributes?.permissions ?? []).some(
(declared) => declared.name === permission,
)
) {
return `${manifest.id}.${permission}`;
}
if (coreGroups().has(head)) return permission;
// Another plugin's registered group, which is the cross-plugin check.
+1 -1
View File
@@ -106,7 +106,7 @@ export class PluginSecretActorError extends Error {
constructor(pluginId: string, key: string) {
super(
`A read of "${pluginId}:${key}" has no acting user. Pass one with ` +
`ctx.secrets.forUser(userId).getShared(...).`,
`ctx.secrets.getShared(pluginId, key, { userId }) or ctx.asUser.`,
);
this.name = "PluginSecretActorError";
}
+9 -1
View File
@@ -309,6 +309,8 @@ function recordUnsatisfied(
export interface ServiceCallContext {
/** Resolves the user this call acts as. */
resolveUserId: () => string | undefined;
/** Called when a handle is bound to a named user with asUser(). */
nameUser?: (userId: string) => void;
/** Checks a role permission for a user. */
hasPermission: (userId: string, permission: string) => Promise<boolean>;
/**
@@ -359,7 +361,13 @@ export function createServiceHandle<T extends object>(
createServiceHandle<T>(
service,
consumerPluginId,
{ ...context, resolveUserId: () => userId },
{
...context,
resolveUserId: () => {
context.nameUser?.(userId);
return userId;
},
},
name,
);
}
+46 -6
View File
@@ -30,6 +30,7 @@ import { pluginLogger } from "../utils/logger.js";
import { recordConflict } from "./conflicts.js";
import { extractWebSocketToken } from "../utils/ws-auth.js";
import { runAsActor } from "./actor.js";
import { isPluginInstalled } from "./http.js";
const PLUGIN_WS_PREFIX = "/plugin-ws/";
@@ -138,8 +139,13 @@ async function handlePluginUpgrade(
const route = routes.get(key(parsed.pluginId, parsed.path));
if (!route) {
// Claimed by the prefix but nothing is listening: answering here is more
// honest than leaving the socket hanging for a plugin that is disabled.
reject(socket, 404, "Not Found");
// honest than leaving the socket hanging. A disabled plugin has no routes
// left, so an installed id means "off", not "missing".
if (isPluginInstalled(parsed.pluginId)) {
reject(socket, 503, "Service Unavailable");
} else {
reject(socket, 404, "Not Found");
}
return true;
}
@@ -164,11 +170,14 @@ async function handlePluginUpgrade(
userId = (await verifyToken(request)) ?? "";
}
// A guest on a public socket has no actor at all, the same as a public
// HTTP route, so nothing downstream mistakes a placeholder for a user.
const asCaller = (fn: () => void) =>
userId ? runAsActor(userId, "request", fn) : fn();
if (route.rawHandler) {
const rawHandler = route.rawHandler;
runAsActor(userId || "anonymous", "request", () =>
rawHandler(request, socket, head, userId),
);
asCaller(() => rawHandler(request, socket, head, userId));
return true;
}
@@ -195,7 +204,7 @@ async function handlePluginUpgrade(
ws.close(1011, "No handler");
return;
}
runAsActor(userId || "anonymous", "request", () => {
asCaller(() => {
void Promise.resolve(handler({ ...connection, socket: ws })).catch(
(error) => {
pluginLogger.error(
@@ -260,6 +269,7 @@ export function registerPluginWsRoute(
declared,
handler,
});
if (options.public) auditPublicSocket(pluginId, normalized);
servers.set(routeKey, new WebSocketServer({ noServer: true }));
pluginLogger.info(`Mounted /plugin-ws/${pluginId}${normalized}`, {
@@ -292,6 +302,7 @@ export function registerPluginWsUpgrade(
declared,
rawHandler: handler,
});
if (options.public) auditPublicSocket(pluginId, normalized);
pluginLogger.info(`Mounted raw upgrade /plugin-ws/${pluginId}${normalized}`, {
operation: "plugin_ws_mount",
@@ -331,6 +342,35 @@ export function getRegisteredWsRoutes(): string[] {
return [...routes.keys()];
}
/** The socket paths a plugin serves without core's login check. */
export function getPluginPublicWsRoutes(pluginId: string): string[] {
return [...routes.values()]
.filter((route) => route.pluginId === pluginId && route.options.public)
.map((route) => route.path);
}
/** Same record the HTTP side writes for its public routes. */
function auditPublicSocket(pluginId: string, path: string): void {
pluginLogger.warn(
`Plugin ${pluginId} serves /plugin-ws/${pluginId}${path} without core authentication`,
{ operation: "plugin_ws_public" },
);
void import("../utils/audit-logger.js")
.then(({ logAudit }) =>
logAudit({
userId: "system",
username: `plugin:${pluginId}`,
action: "plugin_ws_public_route",
resourceType: "plugin",
resourceId: pluginId,
resourceName: pluginId,
details: `unauthenticated socket: ${path}`,
success: true,
}),
)
.catch(() => {});
}
/** Test seam. */
export function resetPluginWebSockets(): void {
for (const routeKey of [...routes.keys()]) {
@@ -67,6 +67,8 @@ export function fakeFactory(state: AuthState) {
delete: async (id: string) => state.users.delete(id),
}),
createCurrentUserAuthRepository: () => ({
listIdentitiesForUser: async (userId: string) =>
state.identities.filter((row) => row.userId === userId),
findIdentity: async (providerId: string, subject: string) =>
state.identities.find(
(row) => row.providerId === providerId && row.subject === subject,
@@ -271,20 +271,25 @@ describe("withHostPluginSettings", () => {
});
describe("writeHostPluginSettings", () => {
it("JSON-stringifies each value under the plugin's own namespace", async () => {
await writeHostPluginSettings("docker", 7, {
it("writes each value through the settings service, under the plugin's own namespace", async () => {
await writeHostPluginSettings(DOCKER, 7, {
enableDocker: true,
socketPath: null,
socketPath: "/var/run/docker.sock",
});
expect(setCalls).toEqual([
["docker", "host", "7", "enableDocker", "true"],
["docker", "host", "7", "socketPath", "null"],
["docker", "host", "7", "socketPath", '"/var/run/docker.sock"'],
]);
});
it("refuses a key the manifest does not declare", async () => {
await writeHostPluginSettings(DOCKER, 7, { injected: "x" });
expect(setCalls).toEqual([]);
});
it("skips a field whose value is undefined", async () => {
await writeHostPluginSettings("docker", 7, {
await writeHostPluginSettings(DOCKER, 7, {
enableDocker: true,
socketPath: undefined,
});
@@ -10,9 +10,11 @@ import request from "supertest";
import { describe, expect, it, vi } from "vitest";
const routers = vi.hoisted(() => new Map<string, express.Router>());
const installed = vi.hoisted(() => new Set<string>());
vi.mock("../../../plugins/http.js", () => ({
getPluginRouter: (id: string) => routers.get(id),
isPluginInstalled: (id: string) => installed.has(id),
}));
const { mountPluginLegacyPaths } =
@@ -64,11 +66,18 @@ describe("mountPluginLegacyPaths", () => {
});
});
it("answers 404 while the plugin serves no router", async () => {
it("answers 404 for a plugin that is not installed", async () => {
const app = appWith([{ id: "gone", legacyPaths: ["/gone/hook"] }]);
const response = await request(app).post("/gone/hook/x");
expect(response.status).toBe(404);
});
it("answers 503 for an installed plugin that serves no router", async () => {
installed.add("paused");
const app = appWith([{ id: "paused", legacyPaths: ["/paused/hook"] }]);
const response = await request(app).post("/paused/hook/x");
expect(response.status).toBe(503);
});
});
describe("legacy redirects", () => {
+14 -5
View File
@@ -47,8 +47,13 @@ vi.mock("../../plugins/permissions.js", async () => {
throw new PluginCapabilityError(pluginId, capability);
}
},
capabilityRefused: (pluginId: string, capability: string) =>
new PluginCapabilityError(pluginId, capability),
};
});
/** The access check behind trackSession and reportLogin is async. */
const settle = () => new Promise((resolve) => setTimeout(resolve, 0));
vi.mock("../../plugins/actor.js", () => ({ getActor: () => h.actor }));
vi.mock("../../utils/permission-manager.js", () => ({
PermissionManager: {
@@ -455,7 +460,7 @@ describe("ctx.hosts.delete", () => {
});
describe("ctx.hosts session tracking and activity", () => {
it("counts a live session through core and releases it", () => {
it("counts a live session through core and releases it", async () => {
h.sessions = [];
h.released = [];
const hosts = createPluginHosts({
@@ -463,6 +468,7 @@ describe("ctx.hosts session tracking and activity", () => {
audit: vi.fn(async () => {}),
});
const release = hosts.trackSession(4);
await settle();
expect(h.sessions).toEqual([4]);
release();
expect(h.released).toEqual([4]);
@@ -498,11 +504,11 @@ describe("ctx.hosts.status", () => {
manifest: manifest(["hosts:read"]),
audit: vi.fn(async () => {}),
});
expect(hosts.status.get(4)).toEqual({
await expect(hosts.status.get(4)).resolves.toEqual({
status: "reachable",
lastChecked: "t",
});
expect(hosts.status.get(5)).toBeNull();
await expect(hosts.status.get(5)).resolves.toBeNull();
await expect(hosts.status.check(5)).resolves.toEqual({
status: "offline",
lastChecked: "5",
@@ -514,7 +520,9 @@ describe("ctx.hosts.status", () => {
manifest: manifest([]),
audit: vi.fn(async () => {}),
});
expect(() => hosts.status.get(4)).toThrow(PluginCapabilityError);
await expect(hosts.status.get(4)).rejects.toBeInstanceOf(
PluginCapabilityError,
);
expect(() => hosts.status.reportLogin(4, { ok: true })).toThrow(
PluginCapabilityError,
);
@@ -526,7 +534,7 @@ describe("ctx.hosts.status", () => {
);
});
it("passes login reports on to core", () => {
it("passes login reports on to core", async () => {
h.statusReports = [];
h.granted = new Set(["hosts:read"]);
const hosts = createPluginHosts({
@@ -534,6 +542,7 @@ describe("ctx.hosts.status", () => {
audit: vi.fn(async () => {}),
});
hosts.status.reportLogin(4, { ok: false, hostKeyChanged: true });
await settle();
expect(h.statusReports).toEqual([
{ hostId: 4, ok: false, hostKeyChanged: true },
]);
+53 -4
View File
@@ -31,6 +31,19 @@ vi.mock("../../plugins/permissions.js", async () => {
throw new PluginCapabilityError(pluginId, capability);
}
},
hasCapability: async (
_pluginId: string,
capability: string,
declared: readonly string[],
) => declared.includes(capability) && h.granted.has(capability),
hasCachedCapability: (
_pluginId: string,
capability: string,
declared: readonly string[],
) => declared.includes(capability) && h.granted.has(capability),
warmPluginGrants: () => {},
capabilityRefused: (pluginId: string, capability: string) =>
new PluginCapabilityError(pluginId, capability),
};
});
vi.mock("../../plugins/actor.js", () => ({
@@ -171,7 +184,7 @@ describe("ctx.ssh", () => {
});
});
it("runs background work on a resolved host as its owner", async () => {
it("never believes a host object's own userId", async () => {
h.actor = undefined;
h.granted = new Set(["ssh:connect", "credentials:use"]);
const ssh = createPluginSsh({
@@ -179,15 +192,21 @@ describe("ctx.ssh", () => {
bag: new DisposableBag("fixture"),
audit: vi.fn(async () => {}),
});
await ssh.connect({
const forged = {
id: 3,
ip: "h",
port: 22,
username: "u",
userId: "owner",
});
expect(h.connects[0].options).toMatchObject({ userId: "owner" });
};
await expect(ssh.connect(forged)).rejects.toThrow(/acting user/);
await expect(ssh.connect(3)).rejects.toThrow(/acting user/);
expect(h.connects).toEqual([]);
h.actor = "caller";
await ssh.connect(forged);
expect(h.connects[0].options).toMatchObject({ userId: "caller" });
expect(h.connects[0].target).toMatchObject({ userId: "caller" });
});
it("passes a given stream through to the pipeline, gated like any connect", async () => {
@@ -343,7 +362,37 @@ describe("ctx.auth", () => {
expect(getSshAuthProvider("corp")).toBeUndefined();
});
it("never calls a keyboard-interactive handler whose grant is missing", async () => {
h.granted = new Set();
const bag = new DisposableBag("fixture");
const auth = createPluginAuth({
manifest: manifest(["auth:provide"], { keyboardInteractive: ["gate"] }),
bag,
audit: vi.fn(async () => {}),
});
const detect = vi.fn(() => null);
const autoAnswerPasswords = vi.fn(() => true);
auth.registerKeyboardInteractiveHandler({
id: "gate",
label: "Gate",
detect,
autoAnswerPasswords,
});
classifyKeyboardInteractive(
{
name: "Gate sign-in",
instructions: "",
prompts: [{ prompt: "Password:", echo: false }],
},
{ id: 1, ip: "10.0.0.1", port: 22, username: "root", password: "pw" },
);
expect(detect).not.toHaveBeenCalled();
expect(autoAnswerPasswords).not.toHaveBeenCalled();
await bag.disposeAll();
});
it("registers a keyboard-interactive handler that sees only its own host settings", async () => {
h.granted = new Set(["auth:provide"]);
const bag = new DisposableBag("fixture");
const auth = createPluginAuth({
manifest: manifest(["auth:provide"], { keyboardInteractive: ["gate"] }),
@@ -29,9 +29,31 @@ vi.mock("../../plugins/permissions.js", async () => {
throw new PluginCapabilityError(pluginId, capability);
}
},
hasCapability: async (
_pluginId: string,
capability: string,
declared: readonly string[],
) => declared.includes(capability) && h.granted.has(capability),
hasCachedCapability: (
_pluginId: string,
capability: string,
declared: readonly string[],
) => declared.includes(capability) && h.granted.has(capability),
warmPluginGrants: () => {},
capabilityRefused: (pluginId: string, capability: string) =>
new PluginCapabilityError(pluginId, capability),
};
});
vi.mock("../../plugins/actor.js", () => ({ getActor: () => h.actor }));
vi.mock("../../utils/permission-manager.js", () => ({
PermissionManager: {
getInstance: () => ({
canAccessHost: async (_userId: string, hostId: number) => ({
hasAccess: hostId !== 7,
}),
}),
},
}));
vi.mock("../../hosts/connect/core-providers.js", () => ({
ensureCoreSshAuthProviders: () => {},
}));
@@ -90,7 +112,7 @@ const { registerSshAuthProvider, resetSshAuthRegistryForTests } =
const { PluginCapabilityError, PluginSshInteractionError } =
await import("@termix/plugin-sdk/backend");
const ALL = ["ssh:connect", "credentials:use"];
const ALL = ["ssh:connect", "credentials:use", "credentials:read"];
function sshWith(capabilities = ALL) {
const audit = vi.fn(async () => {});
@@ -131,6 +153,14 @@ describe("ctx.ssh.resolveHost", () => {
});
});
it("hides the secrets from a plugin without credentials:read", async () => {
h.granted = new Set(["ssh:connect", "credentials:use"]);
const { ssh } = sshWith(["ssh:connect", "credentials:use"]);
const host = await ssh.resolveHost(3);
expect(host).toMatchObject({ id: 3, ip: "10.0.0.1" });
expect(host).not.toHaveProperty("password");
});
it("answers null for a host this user cannot reach", async () => {
const { ssh } = sshWith();
expect(await ssh.resolveHost(404)).toBeNull();
@@ -147,6 +177,18 @@ describe("ctx.ssh.resolveHost", () => {
});
describe("ctx.ssh.prepare", () => {
it("needs credentials:read, because its config carries the secrets", async () => {
h.granted = new Set(["ssh:connect", "credentials:use"]);
const { ssh } = sshWith(["ssh:connect", "credentials:use"]);
await expect(
ssh.prepare(
{ id: 1, ip: "10.0.0.1", port: 22, username: "root" },
{ client: {} },
),
).rejects.toBeInstanceOf(PluginCapabilityError);
expect(h.built).toEqual([]);
});
it("passes the terminal purpose, interactivity and host key socket through", async () => {
const { ssh } = sshWith();
const socket = { send: vi.fn() };
@@ -218,6 +260,27 @@ describe("ctx.ssh browser sign-in", () => {
);
});
it("will not start one for a host the actor cannot reach", async () => {
const started = vi.fn(async () => {});
registerSshAuthProvider({
type: "fixture-auth",
pluginId: "core",
labelKey: "k",
interaction: "fixture",
startInteraction: started,
prepare: async () => ({ status: "ready" }),
});
const { ssh } = sshWith();
await expect(
ssh.startInteraction("fixture", {
hostId: 7,
socket: null,
requestOrigin: "",
}),
).rejects.toBeInstanceOf(PluginSshInteractionError);
expect(started).not.toHaveBeenCalled();
});
it("says why when nothing can start it", async () => {
const { ssh } = sshWith();
await expect(
+24 -12
View File
@@ -146,39 +146,51 @@ describe("splitStatements", () => {
describe("assertOwnedTables", () => {
it("allows a table in the plugin's own namespace", () => {
expect(() =>
assertOwnedTables("my-plugin", [
assertOwnedTables(
"my-plugin",
"CREATE TABLE IF NOT EXISTS p_my_plugin_thing (id integer)",
]),
),
).not.toThrow();
});
it("rejects a table outside the plugin's namespace", () => {
expect(() =>
assertOwnedTables("my-plugin", ["CREATE TABLE users (id integer)"]),
).toThrow(/may only create or alter tables prefixed "p_my_plugin_"/);
assertOwnedTables("my-plugin", "CREATE TABLE users (id integer)"),
).toThrow(/"users", which is not prefixed "p_my_plugin_"/);
});
it("rejects altering a core table", () => {
expect(() =>
assertOwnedTables("my-plugin", [
"ALTER TABLE ssh_data ADD COLUMN x text",
]),
assertOwnedTables("my-plugin", "ALTER TABLE ssh_data ADD COLUMN x text"),
).toThrow(/p_my_plugin_/);
});
it("allows the one plugin that may adopt a legacy table", () => {
it("allows the bundled plugin that adopts a legacy table", () => {
expect(() =>
assertOwnedTables("fleets", [
assertOwnedTables(
"fleets",
'ALTER TABLE "fleets" RENAME TO "p_fleets_fleets"',
]),
{ bundled: true },
),
).not.toThrow();
});
it("gives a user plugin with that id no legacy exemption", () => {
expect(() =>
assertOwnedTables(
"fleets",
'ALTER TABLE "fleets" RENAME TO "p_fleets_fleets"',
),
).toThrow(/"fleets", which is not prefixed/);
});
it("names the owner when another plugin tries to adopt its table", () => {
expect(() =>
assertOwnedTables("workspaces", [
assertOwnedTables(
"workspaces",
'ALTER TABLE "fleets" RENAME TO "p_workspaces_fleets"',
]),
{ bundled: true },
),
).toThrow(/belongs to the "fleets" plugin/);
});
@@ -285,7 +285,9 @@ describe("adopting a legacy core table", () => {
"0001_adopt.sql",
adoptLegacyTableSql("sqlite", "fleets", "p_fleets_fleets"),
);
await applyPluginMigrations("fleets", root, createRunner());
await applyPluginMigrations("fleets", root, createRunner(), {
bundled: true,
});
expect(tableExists("fleets")).toBe(false);
const rows = sqlite
@@ -319,7 +321,9 @@ describe("adopting a legacy core table", () => {
);
await expect(
applyPluginMigrations("workspaces", root, createRunner()),
applyPluginMigrations("workspaces", root, createRunner(), {
bundled: true,
}),
).rejects.toThrow(/belongs to the "fleets" plugin/);
// Refused before execution, so the legacy table is still there.
@@ -0,0 +1,215 @@
/**
* The actor cannot be spoofed. It is set by a request or by ctx.asUser, and
* every other way of naming a user (a service handle, a shared secret read)
* is audited the same way asUser is. RBAC inside ctx.hosts and ctx.ssh is
* applied for that actor, so a user without access to a host cannot reach it
* through a plugin.
*/
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const auditEntries: Array<Record<string, unknown>> = [];
const h = vi.hoisted(() => ({
reachable: new Set<number>([1]),
resolvedFor: [] as Array<{ id: number; userId: string }>,
}));
vi.mock("../../../utils/audit-logger.js", () => ({
logAudit: async (entry: Record<string, unknown>) => {
auditEntries.push(entry);
},
}));
vi.mock("../../../database/repositories/factory.js", () => ({
createCurrentPluginPermissionGrantRepository: () => ({
listByPlugin: async () =>
["hosts:read", "ssh:connect", "credentials:use"].map((capability) => ({
capability,
})),
}),
createCurrentHostResolutionRepository: () => ({
findHostOwnerId: async () => "owner",
findHostById: async (id: number) => ({
id,
userId: "owner",
name: "box",
ip: "10.0.0.1",
port: 22,
username: "root",
tags: null,
folder: null,
authType: "password",
}),
}),
}));
vi.mock("../../../utils/permission-manager.js", () => ({
PermissionManager: {
getInstance: () => ({
hasPermission: async () => true,
canAccessHost: async (_userId: string, hostId: number) => ({
hasAccess: h.reachable.has(hostId),
isOwner: false,
isShared: h.reachable.has(hostId),
}),
}),
},
}));
vi.mock("../../../hosts/host-resolver.js", () => ({
resolveHostById: async (id: number, userId: string) => {
h.resolvedFor.push({ id, userId });
return h.reachable.has(id) ? { id, userId: "owner", ip: "10.0.0.1" } : null;
},
resolveHostBySyncId: async () => null,
}));
const { createPluginContext, createPluginHandle } =
await import("../../../plugins/ctx.js");
const { invalidatePluginPermissionCache } =
await import("../../../plugins/permissions.js");
const { runAsActor, getActor } = await import("../../../plugins/actor.js");
const { clearServiceRegistry } =
await import("../../../plugins/service-registry.js");
function contextFor(pluginId: string, extra: Record<string, unknown> = {}) {
const handle = createPluginHandle(pluginId, { activate: () => {} });
return createPluginContext(
{
id: pluginId,
name: pluginId,
version: "1.0.0",
description: "",
author: { name: "test" },
license: "MIT",
category: "Productivity",
engine: { termix: ">=2.9.0", api: "1" },
capabilities: ["hosts:read", "ssh:connect", "credentials:use"],
...extra,
} as never,
handle,
);
}
const asUserLines = () =>
auditEntries.filter((entry) => entry.action === "plugin_as_user");
beforeEach(() => {
auditEntries.length = 0;
h.resolvedFor = [];
h.reachable = new Set([1]);
invalidatePluginPermissionCache();
});
afterEach(() => clearServiceRegistry());
describe("naming a user is audited like ctx.asUser", () => {
function provider() {
const ctx = contextFor("provider", {
provides: [
{ service: "sample.echo", version: "1.0.0", permission: "x.use" },
],
});
ctx.services.provide("sample.echo", {
whoAmI: async () => getActor(),
});
return ctx;
}
it("ctx.asUser writes an as_user line", async () => {
const ctx = contextFor("caller");
await ctx.asUser("victim", async () => undefined);
expect(asUserLines()).toHaveLength(1);
expect(asUserLines()[0]).toMatchObject({ resourceId: "caller" });
});
it("services.get({ userId }) for someone else writes one too", async () => {
provider();
const caller = contextFor("caller");
const handle = caller.services.get<{ whoAmI: () => Promise<string> }>(
"sample.echo",
{ userId: "victim" },
);
await runAsActor("alice", "request", async () => {
expect(await handle.whoAmI()).toBe("victim");
});
expect(asUserLines()).toHaveLength(1);
expect(String(asUserLines()[0].details)).toMatch(/victim/);
});
it("a handle's asUser(userId) writes one too", async () => {
provider();
const caller = contextFor("caller");
const handle = caller.services.get<{
asUser: (userId: string) => { whoAmI: () => Promise<string> };
}>("sample.echo");
await runAsActor("alice", "request", async () => {
expect(await handle.asUser("victim").whoAmI()).toBe("victim");
});
expect(asUserLines()).toHaveLength(1);
});
it("naming the actor itself is not a switch and writes nothing", async () => {
provider();
const caller = contextFor("caller");
const handle = caller.services.get<{ whoAmI: () => Promise<string> }>(
"sample.echo",
{ userId: "alice" },
);
await runAsActor("alice", "request", () => handle.whoAmI());
expect(asUserLines()).toHaveLength(0);
});
it("the provider sees the user the permission was checked for", async () => {
provider();
const caller = contextFor("caller");
const handle = caller.services.get<{ whoAmI: () => Promise<string> }>(
"sample.echo",
);
await runAsActor("alice", "request", async () => {
expect(await handle.whoAmI()).toBe("alice");
});
});
});
describe("a user cannot reach a host they have no access to", () => {
it("ctx.hosts answers nothing for it", async () => {
const ctx = contextFor("reader");
await runAsActor("alice", "request", async () => {
expect(await ctx.hosts.get(99)).toBeNull();
expect(await ctx.hosts.status.get(99)).toBeNull();
expect(await ctx.hosts.status.check(99)).toBeNull();
const access = await ctx.hosts.checkAccess(99, "connect");
expect(access.hasAccess).toBe(false);
});
});
it("ctx.ssh resolves it as the actor and gets nothing", async () => {
const ctx = contextFor("reader");
await runAsActor("alice", "request", async () => {
expect(await ctx.ssh.resolveHost(99)).toBeNull();
});
expect(h.resolvedFor).toEqual([{ id: 99, userId: "alice" }]);
});
it("ctx.ssh refuses everything without an actor, whatever the host says", async () => {
const ctx = contextFor("reader");
await expect(ctx.ssh.resolveHost(1)).rejects.toThrow(/acting user/);
await expect(
ctx.ssh.connect({
id: 1,
userId: "owner",
ip: "10.0.0.1",
port: 22,
username: "root",
}),
).rejects.toThrow(/acting user/);
await expect(
ctx.ssh.jumpChain([{ hostId: 1 }], {
forHost: {
id: 1,
userId: "owner",
ip: "10.0.0.1",
port: 22,
username: "root",
},
}),
).rejects.toThrow(/acting user/);
});
});
@@ -0,0 +1,177 @@
/**
* /plugin-assets serves only a running plugin's browser files: never a path
* outside its folder (however it is encoded, or through a symlink), never its
* backend or migrations, and always with a fixed content type and nosniff.
*/
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import type { AddressInfo } from "node:net";
import type { Server } from "node:http";
import express from "express";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import {
createPluginAssetsRouter,
resolvePluginAsset,
} from "../../../plugins/assets.js";
let root = "";
let server: Server;
let baseUrl = "";
const plugins = new Map<string, Record<string, unknown>>();
function write(relative: string, content: string) {
const file = path.join(root, relative);
fs.mkdirSync(path.dirname(file), { recursive: true });
fs.writeFileSync(file, content);
}
/** Creates a link, or returns false where the OS will not let us. */
function link(target: string, at: string, type: "file" | "junction"): boolean {
try {
fs.symlinkSync(target, at, type);
return true;
} catch {
return false;
}
}
let fileLinks = false;
let dirLinks = false;
beforeAll(async () => {
root = fs.mkdtempSync(path.join(os.tmpdir(), "plugin-assets-sec-"));
write("gizmo/dist/frontend.js", "export function activate() {}");
write("gizmo/dist/backend.js", "secret()");
write("gizmo/dist/migrations/snapshot.json", "{}");
write("gizmo/locales/en.json", "{}");
write("secret.js", "outside()");
write("secrets/keys.json", "{}");
// A plugin whose manifest puts its frontend at the plugin root, so the
// backend and migrations sit in the served folder.
write("flat/frontend.js", "export function activate() {}");
write("flat/server/main.js", "secret()");
write(
"flat/migrations/sqlite/0001_init.sql",
"CREATE TABLE p_flat_x (id int);",
);
fileLinks = link(
path.join(root, "secret.js"),
path.join(root, "gizmo/dist/escape.js"),
"file",
);
dirLinks = link(
path.join(root, "secrets"),
path.join(root, "gizmo/dist/linked"),
"junction",
);
plugins.set("gizmo", {
id: "gizmo",
dir: path.join(root, "gizmo"),
manifest: { id: "gizmo", version: "1.0.0" },
state: "active",
});
plugins.set("flat", {
id: "flat",
dir: path.join(root, "flat"),
manifest: {
id: "flat",
version: "1.0.0",
frontend: "frontend.js",
backend: "server/main.js",
},
state: "active",
});
plugins.set("stopped", {
id: "stopped",
dir: path.join(root, "gizmo"),
manifest: { id: "stopped", version: "1.0.0" },
state: "disabled",
});
const app = express();
app.use(
"/plugin-assets",
createPluginAssetsRouter((id) => plugins.get(id) as never),
);
server = await new Promise<Server>((resolve) => {
const created = app.listen(0, "127.0.0.1", () => resolve(created));
});
baseUrl = `http://127.0.0.1:${(server.address() as AddressInfo).port}`;
});
afterAll(async () => {
await new Promise<void>((resolve) => server.close(() => resolve()));
fs.rmSync(root, { recursive: true, force: true });
});
const get = (url: string) => fetch(`${baseUrl}/plugin-assets/${url}`);
describe("path traversal", () => {
it.each([
"gizmo/../secret.js",
"gizmo/..%2fsecret.js",
"gizmo/%2e%2e/secret.js",
"gizmo/%2e%2e%2f%2e%2e%2fsecret.js",
"gizmo/..%5csecret.js",
"gizmo/%2e%2e%5c%2e%2e%5csecret.js",
"gizmo/frontend.js%00.js",
"gizmo/locales/..%2f..%2fsecret.js",
"gizmo/%2fetc%2fpasswd.js",
])("refuses %s", async (url) => {
const response = await get(url);
expect(response.status).toBe(404);
expect(await response.text()).not.toContain("outside()");
});
it("refuses a symlinked file that points outside the plugin", () => {
if (!fileLinks) return;
expect(
resolvePluginAsset(plugins.get("gizmo") as never, "escape.js"),
).toBeNull();
});
it("refuses a file inside a symlinked folder that points outside", () => {
if (!dirLinks) return;
expect(
resolvePluginAsset(plugins.get("gizmo") as never, "linked/keys.json"),
).toBeNull();
});
});
describe("server-only files", () => {
it("never serves the backend entry, wherever the manifest put it", async () => {
expect((await get("gizmo/backend.js")).status).toBe(404);
expect((await get("flat/server/main.js")).status).toBe(404);
});
it("never serves anything under migrations", async () => {
expect((await get("gizmo/migrations/snapshot.json")).status).toBe(404);
});
});
describe("a stopped plugin", () => {
it("serves nothing, so its bundle cannot be loaded around the shell", async () => {
expect((await get("stopped/frontend.js")).status).toBe(404);
expect((await get("gizmo/frontend.js")).status).toBe(200);
});
});
describe("headers", () => {
it("sends a fixed content type and nosniff", async () => {
const script = await get("gizmo/frontend.js");
expect(script.headers.get("content-type")).toMatch(/^text\/javascript/);
expect(script.headers.get("x-content-type-options")).toBe("nosniff");
const json = await get("gizmo/locales/en.json");
expect(json.headers.get("content-type")).toMatch(/^application\/json/);
expect(json.headers.get("x-content-type-options")).toBe("nosniff");
const missing = await get("gizmo/nothing.js");
expect(missing.headers.get("x-content-type-options")).toBe("nosniff");
});
});
@@ -0,0 +1,349 @@
/**
* Auth: second factors fail closed, the public login list leaks nothing,
* external sign-in cannot claim someone else's account or username, and the
* lockout guard keeps password login on when nothing else can sign anyone in.
*/
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import bcrypt from "bcryptjs";
import {
createAuthState,
fakeAuthManager,
fakeRequest,
type AuthState,
} from "../../auth/auth-test-helpers.js";
const h = vi.hoisted(() => ({
state: null as unknown as AuthState,
manager: null as unknown as ReturnType<
typeof import("../../auth/auth-test-helpers.js").fakeAuthManager
>,
trustedProxy: false,
}));
vi.mock("../../../database/repositories/factory.js", async () => {
const helpers = await import("../../auth/auth-test-helpers.js");
const names = Object.keys(helpers.fakeFactory(helpers.createAuthState()));
return Object.fromEntries(
names.map((name) => [
name,
(...args: unknown[]) =>
(
helpers.fakeFactory(h.state) as Record<
string,
(...a: unknown[]) => unknown
>
)[name](...args),
]),
);
});
vi.mock("../../../utils/auth-manager.js", () => ({
AuthManager: { getInstance: () => h.manager },
}));
vi.mock("../../../utils/audit-logger.js", () => ({
logAudit: async (entry: Record<string, unknown>) => {
h.state.audits.push(entry);
},
getRequestMeta: () => ({ ipAddress: "10.0.0.1", userAgent: "test" }),
}));
vi.mock("../../../hosts/internal-events.js", () => ({
emitInternalEvent: vi.fn(),
}));
vi.mock("../../../utils/logger.js", () => {
const log = {
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
success: vi.fn(),
debug: vi.fn(),
};
return {
authLogger: log,
databaseLogger: log,
sshLogger: log,
logger: log,
systemLogger: log,
};
});
vi.mock("../../../utils/database-save-trigger.js", () => ({
DatabaseSaveTrigger: { forceSave: vi.fn(async () => {}) },
}));
vi.mock("../../../utils/permission-manager.js", () => ({
PermissionManager: {
getInstance: () => ({ invalidateUserPermissionCache: vi.fn() }),
},
}));
vi.mock("../../../utils/trusted-proxy-auth.js", () => ({
isTrustedProxyAuthEnabled: () => h.trustedProxy,
}));
vi.mock("../../../utils/shared-host-secrets-manager.js", () => ({
SharedHostSecretsManager: {
getInstance: () => ({ snapshotForUserRoles: async () => {} }),
},
}));
vi.mock("../../../utils/shared-credential-secrets-manager.js", () => ({
SharedCredentialSecretsManager: {
getInstance: () => ({ snapshotForUserRoles: async () => {} }),
},
}));
const { runLogin, resetPendingLoginsForTests } =
await import("../../../auth/login-pipeline.js");
const { verifyPasswordLogin } =
await import("../../../auth/builtin-login-methods.js");
const { getPasswordLoginStatus } = await import("../../../auth/core-auth.js");
const { registerLoginMethod, registerSecondFactor } =
await import("../../../auth/registry.js");
const { listPublicLoginMethods } =
await import("../../../database/routes/auth-routes.js");
const { loginRateLimiter } =
await import("../../../utils/login-rate-limiter.js");
const PASSWORD = "correct-horse";
function addUser(overrides: Record<string, unknown> = {}) {
const user = {
id: "u1",
username: "alice",
passwordHash: bcrypt.hashSync(PASSWORD, 4),
isAdmin: false,
isOidc: false,
...overrides,
};
h.state.users.set(user.id as string, user as never);
return user;
}
async function passwordIdentity() {
const req = fakeRequest({ body: { username: "alice", password: PASSWORD } });
return { req, identity: await verifyPasswordLogin(req as never) };
}
const disposers: Array<() => void> = [];
beforeEach(() => {
h.state = createAuthState();
h.manager = fakeAuthManager(h.state);
h.trustedProxy = false;
resetPendingLoginsForTests();
loginRateLimiter.resetAttempts("10.0.0.1", "alice");
delete process.env.ALLOW_PASSWORD_LOGIN;
});
afterEach(() => {
while (disposers.length) disposers.pop()?.();
});
describe("second factors fail closed", () => {
it("refuses the login when a factor cannot say whether the user is enrolled", async () => {
addUser();
disposers.push(
registerSecondFactor({
id: "flaky",
pluginId: "fixture",
labelKey: "k",
isEnrolled: async () => {
throw new Error("database is locked");
},
verify: async () => true,
}),
);
const { req, identity } = await passwordIdentity();
await expect(
runLogin(req as never, identity, {
methodId: "password",
rememberMe: false,
}),
).rejects.toMatchObject({ status: 403 });
expect(h.manager.generateJWTToken).not.toHaveBeenCalled();
});
});
describe("the public login list", () => {
it("names methods and enabled instances, and nothing else", async () => {
disposers.push(
registerLoginMethod({
id: "corp-sso",
pluginId: "fixture",
labelKey: "k",
kind: "redirect",
external: true,
describe: async () =>
[
{
id: "1",
label: "Corp",
enabled: true,
clientSecret: "shh",
issuerUrl: "https://idp.internal",
},
{ id: "2", label: "Old", enabled: false },
] as never,
}),
);
const methods = await listPublicLoginMethods();
const sso = methods.find((method) => method.id === "corp-sso");
expect(sso?.instances).toEqual([{ id: "1", label: "Corp" }]);
expect(JSON.stringify(methods)).not.toMatch(/shh|idp\.internal/);
});
});
describe("external sign-in cannot claim another account", () => {
const external = {
kind: "external" as const,
provider: "3",
subject: "sub-1",
email: null,
name: "Alice",
legacyIdentifier: "shared-id",
ssoProviderId: 3,
};
beforeEach(() => {
disposers.push(
registerLoginMethod({
id: "oidc",
pluginId: "sso",
labelKey: "k",
kind: "redirect",
external: true,
}),
);
h.state.settings.set("oidc_auto_provision", "true");
});
async function signIn(identity: Record<string, unknown> = external) {
return runLogin(fakeRequest() as never, identity as never, {
methodId: "oidc",
rememberMe: false,
});
}
function linkedTo(): string | undefined {
return h.state.identities.find((row) => row.subject === "sub-1")?.userId;
}
it("never links a local password account by its old identifier", async () => {
addUser({ id: "local", oidcIdentifier: "shared-id", isOidc: false });
await signIn();
expect(linkedTo()).not.toBe("local");
});
it("never links an account that already has an identity", async () => {
addUser({
id: "taken",
username: "bob",
passwordHash: "",
isOidc: true,
oidcIdentifier: "shared-id",
});
h.state.identities.push({
id: 1,
userId: "taken",
providerId: "9",
subject: "bob",
email: null,
});
await signIn();
expect(linkedTo()).not.toBe("taken");
});
it("never links an account from a different provider", async () => {
addUser({
id: "other",
username: "bob",
passwordHash: "",
isOidc: true,
oidcIdentifier: "shared-id",
ssoProviderId: 7,
});
await signIn();
expect(linkedTo()).not.toBe("other");
});
it("still links a genuine 2.8 account on its first sign-in", async () => {
addUser({
id: "legacy",
username: "Alice",
passwordHash: "",
isOidc: true,
oidcIdentifier: "shared-id",
ssoProviderId: 3,
});
await signIn();
expect(linkedTo()).toBe("legacy");
});
it("never gives a new account a local user's username", async () => {
addUser({ id: "admin-local", username: "admin" });
await signIn({ ...external, name: "admin", legacyIdentifier: undefined });
const created = [...h.state.users.values()].find(
(user) => user.id !== "admin-local",
);
expect(created?.username).not.toBe("admin");
expect(created?.username).toMatch(/^admin-/);
});
it("never renames an existing account onto a local user's username", async () => {
addUser({ id: "admin-local", username: "admin" });
addUser({
id: "sso-user",
username: "carol",
passwordHash: "",
isOidc: true,
});
h.state.identities.push({
id: 1,
userId: "sso-user",
providerId: "3",
subject: "sub-1",
email: null,
});
await signIn({ ...external, name: "admin" });
expect(h.state.users.get("sso-user")?.username).toBe("carol");
});
});
describe("the lockout guard", () => {
it("keeps password login on when it is off and nothing else can sign in", async () => {
process.env.ALLOW_PASSWORD_LOGIN = "false";
await expect(getPasswordLoginStatus()).resolves.toEqual({
allowed: true,
forced: true,
});
});
it("lets it go off once another method has an enabled instance", async () => {
process.env.ALLOW_PASSWORD_LOGIN = "false";
disposers.push(
registerLoginMethod({
id: "corp-sso",
pluginId: "fixture",
labelKey: "k",
kind: "redirect",
describe: async () => [{ id: "1", label: "Corp", enabled: true }],
}),
);
await expect(getPasswordLoginStatus()).resolves.toEqual({
allowed: false,
forced: false,
});
});
it("does not count a method whose instances are all off", async () => {
process.env.ALLOW_PASSWORD_LOGIN = "false";
disposers.push(
registerLoginMethod({
id: "corp-sso",
pluginId: "fixture",
labelKey: "k",
kind: "redirect",
describe: async () => [{ id: "1", label: "Corp", enabled: false }],
}),
);
await expect(getPasswordLoginStatus()).resolves.toMatchObject({
allowed: true,
forced: true,
});
});
});
@@ -0,0 +1,254 @@
/**
* No ctx method hands a plugin plaintext credential material unless the
* plugin holds credentials:read. Every return value is scanned for secret
* keys, nested ones included, so a new field on the host row that carries a
* secret shows up here.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
const SECRET = "s3cret-value";
const h = vi.hoisted(() => ({
granted: new Set<string>(),
actor: "user-1" as string | undefined,
connected: [] as unknown[],
}));
function secretHost(id: number): Record<string, unknown> {
return {
id,
userId: "user-1",
name: "box",
ip: "10.0.0.1",
port: 22,
username: "root",
authType: "password",
tags: null,
folder: null,
password: "s3cret-value",
key: "s3cret-value",
keyPassword: "s3cret-value",
sudoPassword: "s3cret-value",
socks5Password: "s3cret-value",
rdpPassword: "s3cret-value",
vncPassword: "s3cret-value",
telnetPassword: "s3cret-value",
autostartPassword: "s3cret-value",
autostartKey: "s3cret-value",
autostartKeyPassword: "s3cret-value",
terminalConfig: { sudoPassword: "s3cret-value", fontSize: 14 },
socks5ProxyChain: [{ host: "p", password: "s3cret-value" }],
};
}
vi.mock("../../../plugins/permissions.js", async () => {
const { PluginCapabilityError } = await import("@termix/plugin-sdk/backend");
const granted = (capability: string, declared: readonly string[]) =>
declared.includes(capability) && h.granted.has(capability);
return {
assertCapability: async (
pluginId: string,
capability: string,
declared: readonly string[],
) => {
if (!granted(capability, declared)) {
throw new PluginCapabilityError(pluginId, capability);
}
},
hasCapability: async (
_pluginId: string,
capability: string,
declared: readonly string[],
) => granted(capability, declared),
hasCachedCapability: (
_pluginId: string,
capability: string,
declared: readonly string[],
) => granted(capability, declared),
warmPluginGrants: () => {},
capabilityRefused: (pluginId: string, capability: string) =>
new PluginCapabilityError(pluginId, capability),
};
});
vi.mock("../../../plugins/actor.js", () => ({
getActor: () => h.actor,
getActorSessionId: () => undefined,
}));
vi.mock("../../../utils/permission-manager.js", () => ({
PermissionManager: {
getInstance: () => ({
canAccessHost: async () => ({ hasAccess: true, isOwner: true }),
hasPermission: async () => true,
}),
},
}));
vi.mock("../../../database/repositories/factory.js", () => ({
createCurrentHostRepository: () => ({
createEncryptedForUser: async () => secretHost(1),
updateEncryptedForUser: async () => secretHost(1),
listDecryptedByUserId: async () => [secretHost(1), secretHost(2)],
}),
createCurrentHostResolutionRepository: () => ({
findHostsByUserId: async () => [secretHost(1)],
listHostRowsForAccessList: async () => [secretHost(2)],
findHostOwnerId: async () => "user-1",
findHostById: async (id: number) => secretHost(id),
}),
createCurrentRoleRepository: () => ({ listUserRoleIds: async () => [] }),
createCurrentRbacAccessRepository: () => ({
listVisibleHostAccessEntries: async () => [],
}),
}));
vi.mock("../../../database/routes/host-plugin-settings.js", () => ({
applyPluginHostImportSettings: async () => {},
}));
vi.mock("../../../hosts/host-resolver.js", () => ({
resolveHostById: async (id: number) => secretHost(id),
resolveHostBySyncId: async () => secretHost(9),
}));
vi.mock("../../../hosts/connect/connect-host.js", () => ({
connectHost: async (target: unknown) => {
h.connected.push(target);
return {
client: { once: () => {} },
jumpClient: null,
host: typeof target === "number" ? secretHost(target) : target,
dispose: () => {},
};
},
resolveConnectHost: async (target: unknown) =>
typeof target === "number" ? secretHost(target) : target,
}));
const { createPluginHosts, redactHostSecrets, HOST_SECRET_FIELDS } =
await import("../../../plugins/ctx-hosts.js");
const { createPluginSsh } = await import("../../../plugins/ctx-ssh-auth.js");
const { DisposableBag } = await import("../../../plugins/disposables.js");
function manifest(capabilities: string[]) {
return {
id: "fixture",
name: "Fixture",
version: "1.0.0",
capabilities,
} as never;
}
/** Every path in `value` that holds the secret. */
function leaks(value: unknown, path = "$"): string[] {
if (value === SECRET) return [path];
if (Array.isArray(value)) {
return value.flatMap((entry, index) => leaks(entry, `${path}[${index}]`));
}
if (value && typeof value === "object") {
return Object.entries(value).flatMap(([key, entry]) =>
leaks(entry, `${path}.${key}`),
);
}
return [];
}
const audit = vi.fn(async () => {});
beforeEach(() => {
h.actor = "user-1";
h.granted = new Set();
h.connected = [];
audit.mockClear();
});
describe("redactHostSecrets", () => {
it("removes every secret field, nested ones too", () => {
const redacted = redactHostSecrets(secretHost(1));
expect(leaks(redacted)).toEqual([]);
for (const field of HOST_SECRET_FIELDS) {
expect(redacted).not.toHaveProperty(field);
}
expect(redacted).toMatchObject({ id: 1, ip: "10.0.0.1" });
expect(redacted.terminalConfig).toEqual({ fontSize: 14 });
});
});
describe("ctx.hosts never returns secrets", () => {
const all = ["hosts:read", "hosts:write"];
it("list, get, create, update and listOwned", async () => {
h.granted = new Set(all);
const hosts = createPluginHosts({ manifest: manifest(all), audit });
const results = {
list: await hosts.list(),
get: await hosts.get(1),
create: await hosts.create({
name: "n",
ip: "i",
port: 22,
username: "u",
authType: "password",
}),
update: await hosts.update(1, { name: "m" }),
listOwned: await hosts.listOwned(),
};
expect(leaks(results)).toEqual([]);
});
it("audits listOwned, the widest read it has", async () => {
h.granted = new Set(all);
const hosts = createPluginHosts({ manifest: manifest(all), audit });
await hosts.listOwned();
expect(audit).toHaveBeenCalledWith("hosts_list_owned", "2 host(s)", {
success: true,
});
});
});
describe("ctx.ssh hands secrets only to credentials:read", () => {
const connectPair = ["ssh:connect", "credentials:use"];
it("connect() returns the host without its secrets", async () => {
h.granted = new Set(connectPair);
const ssh = createPluginSsh({
manifest: manifest(connectPair),
bag: new DisposableBag("fixture"),
audit,
});
const connection = await ssh.connect(1);
expect(leaks(connection.host)).toEqual([]);
});
it("resolveHost() redacts without credentials:read", async () => {
h.granted = new Set(connectPair);
const ssh = createPluginSsh({
manifest: manifest(connectPair),
bag: new DisposableBag("fixture"),
audit,
});
expect(leaks(await ssh.resolveHost(1))).toEqual([]);
});
it("resolveHost() gives the full host with credentials:read, and audits it", async () => {
const withRead = [...connectPair, "credentials:read"];
h.granted = new Set(withRead);
const ssh = createPluginSsh({
manifest: manifest(withRead),
bag: new DisposableBag("fixture"),
audit,
});
expect(leaks(await ssh.resolveHost(1)).length).toBeGreaterThan(0);
expect(audit).toHaveBeenCalledWith("credentials_read", "host 1", {
success: true,
});
});
it("a redacted host handed back still connects with the real secrets", async () => {
h.granted = new Set(connectPair);
const ssh = createPluginSsh({
manifest: manifest(connectPair),
bag: new DisposableBag("fixture"),
audit,
});
const redacted = await ssh.resolveHost(1);
await ssh.connect(redacted!);
expect(h.connected[0]).toMatchObject({ password: SECRET });
});
});
@@ -0,0 +1,183 @@
/**
* Every privileged ctx method checks its capability and leaves an audit line.
*
* This walks the whole ctx object rather than listing methods by hand, so a
* member added later without a guard fails here until it is either guarded
* or added to UNGATED with a reason.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
const auditEntries: Array<Record<string, unknown>> = [];
vi.mock("../../../database/repositories/factory.js", () => ({
createCurrentPluginPermissionGrantRepository: () => ({
listByPlugin: async () => [],
}),
}));
vi.mock("../../../utils/audit-logger.js", () => ({
logAudit: async (entry: Record<string, unknown>) => {
auditEntries.push(entry);
},
}));
const { createPluginContext, createPluginHandle } =
await import("../../../plugins/ctx.js");
const { invalidatePluginPermissionCache } =
await import("../../../plugins/permissions.js");
const { runAsActor } = await import("../../../plugins/actor.js");
const { PluginCapabilityError } = await import("@termix/plugin-sdk/backend");
const PLUGIN_ID = "walker";
const ACTOR = "user-walker";
/**
* Members that need no capability, and why. Anything not listed here must
* refuse a plugin that declared and was granted nothing.
*/
const UNGATED: Record<string, string> = {
"log.debug": "writes to core's log under the plugin's own tag",
"log.info": "writes to core's log under the plugin's own tag",
"log.warn": "writes to core's log under the plugin's own tag",
"log.error": "writes to core's log under the plugin's own tag",
"events.emit": "namespace check: plugin.<id>.* only without events:core",
"events.on": "namespace check: plugin.* only without events:core",
"sync.registerEntity": "adds the plugin's own table to remote sync",
"sync.recordTombstone": "records a delete of the plugin's own row",
"registry.provide": "the plugin's own registry key",
"registry.consume": "reads what another plugin chose to publish",
"registry.revoke": "removes the plugin's own registry key",
"services.provide": "only services declared in manifest provides",
"services.get": "each call is checked against the service's permission",
"services.providers": "names only",
"secrets.offer": "only secrets declared in manifest providesSecret",
"secrets.withdraw": "the plugin's own offer",
"secrets.getShared": "each read is checked against the secret's permission",
"settings.get": "the plugin's own admin settings",
"settings.set": "the plugin's own admin settings",
"settings.getUser": "the plugin's own user settings",
"settings.setUser": "the plugin's own user settings",
"settings.getHost": "the plugin's own host settings",
"settings.setHost": "the plugin's own host settings",
"settings.getAll": "the plugin's own settings",
"settings.onChange": "the plugin's own settings",
"settings.onValidate": "the plugin's own settings",
"rbac.has": "answers for the actor only",
"rbac.hasFor": "a yes/no about a role permission, grants nothing",
"rbac.require": "route middleware for the plugin's own permissions",
"capabilities.has": "a yes/no about the plugin's own grants",
"disposables.add": "the plugin's own cleanup",
"schedule.every": "the plugin's own timers",
"schedule.after": "the plugin's own timers",
"audit.record": "writes under the runtime's actor, never a plugin value",
asUser: "always audited as plugin_as_user",
currentActor: "reads the actor, cannot set it",
"desktop.available": "a yes/no about the environment",
"http.baseUrl": "reads the request's public origin",
"ssh.poolKey": "a string built from a host the plugin already holds",
"ssh.dropPooled": "closes only this plugin's own pooled connections",
"ssh.classifyKeyboardInteractive": "pure classification of a prompt",
"ssh.autoResponses": "pure mapping of prompts to answers",
"ssh.requiresSecret": "a yes/no about an auth type",
"ssh.supportsBackground": "a yes/no about an auth type",
};
function walk(
value: unknown,
path: string[] = [],
out: Array<{ path: string; fn: (...args: unknown[]) => unknown }> = [],
) {
if (!value || typeof value !== "object") return out;
for (const [key, member] of Object.entries(value)) {
if (path.length === 0 && (key === "manifest" || key === "pluginId")) {
continue;
}
const at = [...path, key];
if (typeof member === "function") {
out.push({ path: at.join("."), fn: member as never });
}
if (member && typeof member === "object") walk(member, at, out);
}
return out;
}
/** Args that get a call past argument checks and into the guard. */
const PLACEHOLDER_ARGS = [
1,
{ id: 1, ip: "10.0.0.1", port: 22, username: "u", type: "x" },
{ purpose: "background", pool: "p" },
() => undefined,
];
function contextWithNothing() {
const handle = createPluginHandle(PLUGIN_ID, { activate: () => {} });
return createPluginContext(
{
id: PLUGIN_ID,
name: "Walker",
version: "1.0.0",
description: "",
author: { name: "test" },
license: "MIT",
category: "Productivity",
engine: { termix: ">=2.9.0", api: "1" },
capabilities: [],
} as never,
handle,
);
}
beforeEach(() => {
auditEntries.length = 0;
invalidatePluginPermissionCache();
});
describe("every privileged ctx member is guarded", () => {
const members = walk(contextWithNothing());
it("finds the members it walks", () => {
expect(members.length).toBeGreaterThan(60);
for (const name of Object.keys(UNGATED)) {
expect(
members.some((member) => member.path === name),
`UNGATED lists ${name}, which ctx no longer has`,
).toBe(true);
}
});
const gated = members.filter((member) => !(member.path in UNGATED));
it.each(gated.map((member) => [member.path]))(
"%s refuses a plugin with no grants and audits it",
async (path) => {
const ctx = contextWithNothing();
const member = walk(ctx).find((entry) => entry.path === path)!;
let caught: unknown;
await runAsActor(ACTOR, "request", async () => {
try {
await member.fn(...PLACEHOLDER_ARGS);
} catch (error) {
caught = error;
}
});
// Sync refusals write their audit line in the background.
await new Promise((resolve) => setTimeout(resolve, 0));
expect(
caught,
`${path} ran without a capability. Guard it, or add it to UNGATED with a reason.`,
).toBeInstanceOf(PluginCapabilityError);
expect(
auditEntries.some(
(entry) =>
entry.resourceId === PLUGIN_ID &&
entry.userId === ACTOR &&
entry.success === false,
),
`${path} refused without an audit line naming the plugin and actor`,
).toBe(true);
},
);
});
@@ -0,0 +1,211 @@
/**
* Data: a plugin migration can only write tables under its own prefix, the
* CLI and the runtime share one checker, a symlink cannot feed in SQL from
* elsewhere, and removing a plugin's data drops only that plugin's tables.
*/
import Database from "better-sqlite3";
import { drizzle } from "drizzle-orm/better-sqlite3";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { findUnownedTableWrites } from "@termix/plugin-sdk/ddl";
import { LEGACY_TABLE_OWNERS } from "@termix/plugin-sdk/db";
const state = vi.hoisted(() => ({
db: null as unknown,
deleted: [] as string[],
}));
vi.mock("../../../utils/logger.js", () => {
const log = { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() };
return { pluginLogger: log };
});
vi.mock("../../../database/db/index.js", () => ({
getDb: () => state.db,
}));
vi.mock("../../../database/db/dialect.js", () => ({
resolveDatabaseDialect: () => "sqlite",
}));
vi.mock("../../../database/repositories/factory.js", () => {
const remove = (what: string) => ({
deleteByPlugin: async (pluginId: string) => {
state.deleted.push(`${what}:${pluginId}`);
return 0;
},
});
return {
createCurrentPluginStorageRepository: () => remove("kv"),
createCurrentPluginMigrationRepository: () => remove("migrations"),
createCurrentPluginPermissionGrantRepository: () => remove("grants"),
createCurrentPluginSettingsRepository: () => remove("settings"),
};
});
const { assertOwnedTables, readMigrations } =
await import("../../../plugins/migrations.js");
const { removePluginData, ownedTableNames } =
await import("../../../plugins/data.js");
describe("the migration checker", () => {
it.each([
["a comment between keyword and name", "DROP TABLE/**/users"],
["a comment with spaces", "DROP TABLE /* x */ users"],
["bracket quoting", "DROP TABLE [users]"],
["double quoting", 'DROP TABLE "users"'],
["back quoting", "DROP TABLE `users`"],
["a second name in a list", "DROP TABLE p_foo_a, users"],
["a rename out of the namespace", "ALTER TABLE p_foo_a RENAME TO users"],
["a MySQL rename in", "RENAME TABLE users TO p_foo_users"],
["an insert", "INSERT INTO users (id) VALUES ('x')"],
["an update", "UPDATE users SET is_admin = 1"],
["a delete", "DELETE FROM sessions"],
["a schema name", "DELETE FROM main.users"],
["an index on a core table", "CREATE INDEX i ON users (id)"],
[
"a trigger",
"CREATE TRIGGER t AFTER INSERT ON p_foo_a BEGIN SELECT 1; END",
],
["a view", "CREATE VIEW users_copy AS SELECT * FROM users"],
["attaching a database", "ATTACH DATABASE 'x.db' AS other"],
["a pragma", "PRAGMA writable_schema = 1"],
["a truncate", "TRUNCATE TABLE users"],
["an unlogged table", "CREATE UNLOGGED TABLE users (id int)"],
["a CTE write", "WITH x AS (SELECT 1) DELETE FROM users"],
])("refuses %s", (_label, sql) => {
expect(findUnownedTableWrites("foo", sql)).not.toEqual([]);
});
it.each([
["its own table", "CREATE TABLE IF NOT EXISTS p_foo_a (id int)"],
[
"a reference to a core table",
"CREATE TABLE p_foo_a (u text REFERENCES users(id))",
],
["copying from a core table", "INSERT INTO p_foo_a SELECT id FROM users"],
["a column rename", "ALTER TABLE p_foo_a RENAME COLUMN a TO b"],
[
"a quoted name in a string",
"INSERT INTO p_foo_a (x) VALUES ('DROP TABLE users')",
],
])("allows %s", (_label, sql) => {
expect(findUnownedTableWrites("foo", sql)).toEqual([]);
});
it("passes every migration a bundled plugin ships", () => {
const plugins = path.resolve(
path.dirname(fileURLToPath(import.meta.url)),
"../../../../../plugins",
);
for (const id of fs.readdirSync(plugins)) {
const legacy = new Set(
Object.entries(LEGACY_TABLE_OWNERS)
.filter(([, owner]) => owner === id)
.map(([table]) => table),
);
for (const dialect of ["sqlite", "postgres", "mysql"]) {
const dir = path.join(plugins, id, "migrations", dialect);
if (!fs.existsSync(dir)) continue;
for (const file of fs.readdirSync(dir)) {
if (!file.endsWith(".sql")) continue;
const sql = fs.readFileSync(path.join(dir, file), "utf8");
expect(
findUnownedTableWrites(id, sql, legacy),
`${id}/${dialect}/${file}`,
).toEqual([]);
}
}
}
});
it("gives a user plugin no legacy exemption at runtime", () => {
const adopt = 'ALTER TABLE "fleets" RENAME TO "p_fleets_fleets"';
expect(() =>
assertOwnedTables("fleets", adopt, { bundled: true }),
).not.toThrow();
expect(() => assertOwnedTables("fleets", adopt)).toThrow(/fleets/);
});
});
describe("reading migrations", () => {
const roots: string[] = [];
afterEach(() => {
while (roots.length) {
fs.rmSync(roots.pop()!, { recursive: true, force: true });
}
});
it("refuses a migration that is a symlink to a file outside the plugin", () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "plugin-mig-link-"));
roots.push(root);
const outside = path.join(root, "evil.sql");
fs.writeFileSync(outside, "DROP TABLE users;");
const dir = path.join(root, "plugin", "migrations", "sqlite");
fs.mkdirSync(dir, { recursive: true });
try {
fs.symlinkSync(outside, path.join(dir, "0001_init.sql"), "file");
} catch {
return;
}
expect(() => readMigrations(path.join(root, "plugin"), "sqlite")).toThrow(
/outside the plugin/,
);
});
});
describe("removePluginData", () => {
let sqlite: Database.Database;
beforeEach(() => {
sqlite = new Database(":memory:");
for (const table of [
"users",
"p_foo_notes",
"p_foo_items",
"p_foo_bar_x",
"p_other_notes",
]) {
sqlite.exec(`CREATE TABLE "${table}" (id INTEGER PRIMARY KEY)`);
}
state.db = drizzle(sqlite);
state.deleted = [];
});
afterEach(() => sqlite.close());
const tables = () =>
(
sqlite
.prepare("SELECT name FROM sqlite_master WHERE type = 'table'")
.all() as Array<{ name: string }>
)
.map((row) => row.name)
.sort();
it("drops only the plugin's own tables, found in the catalog", async () => {
const removed = await removePluginData("foo", {
knownPluginIds: ["foo", "foo-bar", "other"],
});
expect(removed.tables.sort()).toEqual(["p_foo_items", "p_foo_notes"]);
expect(tables()).toEqual(["p_foo_bar_x", "p_other_notes", "users"]);
});
it("takes its settings and secrets with it", async () => {
await removePluginData("foo", { knownPluginIds: ["foo"] });
expect(state.deleted).toContain("settings:foo");
expect(state.deleted).toContain("kv:foo");
expect(state.deleted).toContain("grants:foo");
});
it("leaves a longer prefix another plugin owns", () => {
expect(
ownedTableNames(
"foo",
["p_foo_a", "p_foo_bar_b", "p_food_c"],
["foo-bar"],
),
).toEqual(["p_foo_a"]);
});
});
@@ -0,0 +1,194 @@
/**
* Discovery: a bundled id can never be taken by a user plugin, the folder
* name is the id, nothing a manifest names (or a symlink inside the plugin)
* reaches outside the plugin folder, table prefixes cannot overlap, and the
* manifest validator refuses unknown fields, bad ids and reserved ids.
*/
import { afterEach, describe, expect, it, vi } from "vitest";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { PluginLoader, prefixesOverlap } from "../../../plugins/loader.js";
import { parseManifest } from "../../../plugins/manifest.js";
import { RESERVED_PLUGIN_IDS } from "@termix/plugin-sdk/manifest";
import { createFixturePlugin } from "../fixture-plugin.js";
vi.mock("../../../database/repositories/factory.js", () => ({
createCurrentPluginStorageRepository: () => ({}),
}));
const cleanups: Array<() => void> = [];
function tempRoot(prefix: string): string {
const root = fs.mkdtempSync(path.join(os.tmpdir(), prefix));
cleanups.push(() => fs.rmSync(root, { recursive: true, force: true }));
return root;
}
afterEach(() => {
delete process.env.TERMIX_BUNDLED_PLUGINS_DIR;
delete process.env.DATA_DIR;
while (cleanups.length) cleanups.pop()?.();
});
function validManifest(overrides: Record<string, unknown> = {}) {
const fixture = createFixturePlugin();
cleanups.push(fixture.cleanup);
const manifest = JSON.parse(
fs.readFileSync(path.join(fixture.dir, "manifest.json"), "utf8"),
);
return { ...manifest, ...overrides };
}
describe("shadowing a bundled plugin", () => {
it("is refused even when the bundled one failed to load", async () => {
const bundled = tempRoot("termix-bundled-");
const user = tempRoot("termix-data-");
const userPlugins = path.join(user, "plugins");
fs.mkdirSync(userPlugins, { recursive: true });
// The bundled copy is broken, so it never makes it into the loader.
createFixturePlugin({ id: "ssh-terminal", root: bundled });
fs.writeFileSync(
path.join(bundled, "ssh-terminal", "manifest.json"),
"{ broken",
);
createFixturePlugin({ id: "ssh-terminal", root: userPlugins });
process.env.TERMIX_BUNDLED_PLUGINS_DIR = bundled;
process.env.DATA_DIR = user;
const loaded = await new PluginLoader().loadAll();
expect(loaded).toEqual([]);
});
it("needs the folder name to be the manifest id", async () => {
const fixture = createFixturePlugin({
manifestOverrides: { id: "other-id" },
});
cleanups.push(fixture.cleanup);
await expect(new PluginLoader().load(fixture.dir, "user")).rejects.toThrow(
/does not match manifest id/,
);
});
});
describe("table prefixes", () => {
it("overlap when one id's prefix covers the other's tables", () => {
expect(prefixesOverlap("foo", "foo-bar")).toBe(true);
expect(prefixesOverlap("foo-bar", "foo")).toBe(true);
expect(prefixesOverlap("session", "session-sharing")).toBe(true);
expect(prefixesOverlap("ai", "automations")).toBe(false);
expect(prefixesOverlap("web-endpoint", "webauthn")).toBe(false);
expect(prefixesOverlap("foo", "foo")).toBe(false);
});
it("the loader refuses the second plugin of an overlapping pair", async () => {
const root = tempRoot("termix-overlap-");
createFixturePlugin({ id: "foo", root });
createFixturePlugin({ id: "foo-bar", root });
const loader = new PluginLoader();
await loader.load(path.join(root, "foo"), "bundled");
await expect(
loader.load(path.join(root, "foo-bar"), "user"),
).rejects.toThrow(/overlaps "foo"/);
});
it("no two bundled plugins overlap", () => {
const plugins = path.resolve(
path.dirname(fileURLToPath(import.meta.url)),
"../../../../../plugins",
);
const ids = fs
.readdirSync(plugins, { withFileTypes: true })
.filter((entry) => entry.isDirectory())
.map((entry) => entry.name);
for (const a of ids) {
for (const b of ids) {
expect(prefixesOverlap(a, b), `${a} and ${b}`).toBe(false);
}
}
});
});
describe("paths stay inside the plugin", () => {
it("refuses a backend entry that is a symlink to outside", async () => {
const root = tempRoot("termix-link-");
const outside = path.join(root, "outside.mjs");
fs.writeFileSync(outside, "export function activate() {}");
const fixture = createFixturePlugin({ id: "linker", root });
const entry = path.join(fixture.dir, "backend", "index.mjs");
fs.rmSync(entry);
try {
fs.symlinkSync(outside, entry, "file");
} catch {
return;
}
await expect(new PluginLoader().load(fixture.dir, "user")).rejects.toThrow(
/outside the plugin directory/,
);
});
it.each([
["backend", "../../evil.js"],
["backend", "/etc/evil.js"],
["backend", "C:/evil.js"],
["frontend", "dist\\..\\..\\evil.js"],
["frontend", "."],
["locales", "../../secrets"],
])("the validator refuses %s: %s", (field, value) => {
const { manifest, errors } = parseManifest(
validManifest({ [field]: value }),
);
expect(manifest).toBeUndefined();
expect(errors.join("\n")).toMatch(/relative path inside the plugin/);
});
});
describe("manifest validation", () => {
it.each(RESERVED_PLUGIN_IDS.map((id) => [id]))(
"refuses the reserved id %s",
(id) => {
const { manifest, errors } = parseManifest(validManifest({ id }));
expect(manifest).toBeUndefined();
expect(errors.join("\n")).toMatch(/reserved/);
},
);
it.each([["Bad-Id"], ["1starts-with-digit"], ["has_underscore"], ["a"]])(
"refuses the bad id %s",
(id) => {
expect(parseManifest(validManifest({ id })).manifest).toBeUndefined();
},
);
it("refuses an unknown top-level field", () => {
const { manifest, errors } = parseManifest(
validManifest({ runAsRoot: true }),
);
expect(manifest).toBeUndefined();
expect(errors.join("\n")).toMatch(/runAsRoot/);
});
it("refuses an unknown nested field", () => {
const { manifest } = parseManifest(
validManifest({ author: { name: "x", backdoor: true } }),
);
expect(manifest).toBeUndefined();
});
it("refuses a preset key that is not a plain name", () => {
const presets = {
simple: { __proto__x: true },
balanced: { __proto__x: true },
advanced: { __proto__x: true },
};
const { manifest, errors } = parseManifest(
validManifest({ contributes: { uiPresets: presets } }),
);
expect(manifest).toBeUndefined();
expect(errors.join("\n")).toMatch(/invalid key/);
});
});
@@ -0,0 +1,38 @@
/**
* A program a plugin starts through ctx.process.run gets a small base
* environment, never the server's own, which holds the JWT secret, database
* keys and OIDC client secrets.
*/
import { describe, expect, it } from "vitest";
import { baseEnvironment } from "../../../plugins/ctx-process.js";
describe("ctx.process environment", () => {
it("passes the path, home, temp and proxy through", () => {
const env = baseEnvironment({
PATH: "/usr/bin",
HOME: "/home/termix",
TMPDIR: "/tmp",
HTTPS_PROXY: "http://proxy:3128",
});
expect(env).toEqual({
PATH: "/usr/bin",
HOME: "/home/termix",
TMPDIR: "/tmp",
HTTPS_PROXY: "http://proxy:3128",
});
});
it("drops the server's secrets", () => {
const env = baseEnvironment({
PATH: "/usr/bin",
JWT_SECRET: "jwt",
DATABASE_KEY: "db",
DATABASE_URL: "postgres://user:pass@db/termix",
OIDC_CLIENT_SECRET: "oidc",
INTERNAL_AUTH_TOKEN: "internal",
GUACAMOLE_ENCRYPTION_KEY: "guac",
});
expect(Object.keys(env)).toEqual(["PATH"]);
});
});
@@ -0,0 +1,245 @@
/**
* RBAC: a plugin's permissions live in its own namespace and nowhere else.
* A plugin cannot take a core group's name as its id, its own declared names
* resolve to itself, and a role default is recorded as applied only once the
* role really has it.
*/
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const state = vi.hoisted(() => ({
/** roleName -> the permission array stored on that role. */
roles: new Map<string, { id: number; permissions: string[] }>(),
/** The rbac_applied_defaults rows, as "<role>:<permission>". */
appliedDefaults: new Set<string>(),
/** The rbac_known_permissions rows. */
known: new Set<string>(),
warnings: [] as string[],
failUpdate: false,
}));
vi.mock("../../../upgrade/boot-migrations.js", () => ({
runPluginDataMigrations: async () => {},
}));
vi.mock("../../../utils/logger.js", () => ({
pluginLogger: {
debug: vi.fn(),
info: vi.fn(),
warn: vi.fn((message: string) => {
state.warnings.push(message);
}),
error: vi.fn((message: string) => {
state.warnings.push(message);
}),
success: vi.fn(),
},
databaseLogger: {
debug: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
success: vi.fn(),
},
}));
vi.mock("../../../utils/audit-logger.js", () => ({
logAudit: vi.fn(async () => {}),
getAuditUsername: vi.fn(async (userId: string) => `user:${userId}`),
}));
vi.mock("../../../utils/permission-manager.js", () => ({
PermissionManager: {
getInstance: () => ({
hasPermission: async () => true,
invalidateUserPermissionCache: vi.fn(),
}),
},
}));
vi.mock("../../../database/repositories/factory.js", () => ({
createCurrentRoleRepository: () => ({
findRoleByName: async (name: string) => {
const role = state.roles.get(name);
if (!role) return null;
return {
id: role.id,
name,
permissions: JSON.stringify(role.permissions),
};
},
updateRole: async (id: number, updates: { permissions?: string }) => {
if (state.failUpdate) throw new Error("database is locked");
for (const [name, role] of state.roles) {
if (role.id !== id) continue;
if (updates.permissions === undefined) continue;
state.roles.set(name, {
id,
permissions: JSON.parse(updates.permissions) as string[],
});
}
},
listRoleUserIds: async () => [],
listRoles: async () =>
[...state.roles.entries()].map(([name, role]) => ({
id: role.id,
name,
permissions: JSON.stringify(role.permissions),
})),
}),
createCurrentRbacPermissionRepository: () => ({
listKnown: async () =>
[...state.known].map((permission) => ({ permission, pluginId: null })),
recordKnown: async (rows: { permission: string }[]) => {
for (const row of rows) state.known.add(row.permission);
return rows.length;
},
listAppliedDefaults: async () =>
[...state.appliedDefaults].map((key) => {
const [roleName, ...rest] = key.split(":");
return { roleName, permission: rest.join(":") };
}),
recordAppliedDefaults: async (
rows: { roleName: string; permission: string }[],
) => {
for (const row of rows) {
state.appliedDefaults.add(`${row.roleName}:${row.permission}`);
}
return rows.length;
},
}),
}));
const { PluginLoader } = await import("../../../plugins/loader.js");
const { createFixturePlugin } = await import("../fixture-plugin.js");
const { registerPluginPermissions, resetPermissionCatalog, isValidPermission } =
await import("../../../utils/permission-catalog.js");
const { resolvePermission } = await import("../../../plugins/rbac.js");
const { parseManifest } = await import("../../../plugins/manifest.js");
function permission(name: string, defaultRoles?: string[]) {
return {
name,
titleKey: `permissions.${name}.title`,
descriptionKey: `permissions.${name}.description`,
...(defaultRoles ? { defaultRoles } : {}),
};
}
beforeEach(() => {
state.roles.clear();
state.appliedDefaults.clear();
state.known.clear();
state.warnings.length = 0;
state.failUpdate = false;
resetPermissionCatalog();
});
afterEach(() => resetPermissionCatalog());
describe("a plugin cannot mint core permissions", () => {
it.each([["admin"], ["hosts"], ["credentials"]])(
"a plugin called %s never loads",
async (id) => {
const fixture = createFixturePlugin({
id,
capabilities: ["kv:own"],
manifestOverrides: {
contributes: { permissions: [permission("backdoor", ["user"])] },
},
});
try {
await expect(
new PluginLoader().load(fixture.dir, "user"),
).rejects.toThrow(/reserved/);
expect(isValidPermission(`${id}.backdoor`)).toBe(false);
} finally {
fixture.cleanup();
}
},
);
});
describe("resolving a permission name", () => {
it("a name the plugin declares is its own, whatever its first segment", () => {
registerPluginPermissions({
group: "billing",
pluginId: "billing",
label: "Billing",
permissions: ["billing.pay"],
});
const parsed = parseManifest({
id: "sample",
name: "Sample",
version: "1.0.0",
description: "A fixture.",
author: { name: "Termix" },
license: "MIT",
category: "Productivity",
engine: { termix: ">=2.9.0", api: "1" },
capabilities: ["kv:own"],
contributes: { permissions: [permission("billing.pay")] },
}).manifest!;
expect(resolvePermission(parsed, "billing.pay")).toBe("sample.billing.pay");
});
});
describe("role defaults", () => {
it("are not marked applied when the role update fails, so the next boot retries", async () => {
state.roles.set("user", { id: 2, permissions: ["hosts.*"] });
state.failUpdate = true;
const fixture = createFixturePlugin({
id: "docker",
capabilities: ["kv:own"],
manifestOverrides: {
category: "Infrastructure",
contributes: { permissions: [permission("run", ["user"])] },
},
});
const { resetPluginRuntime, getPluginRuntime, activatePlugin } =
await import("../../../plugins/index.js");
resetPluginRuntime();
const loader = getPluginRuntime().loader;
try {
const plugin = await loader.load(fixture.dir);
await activatePlugin(plugin.id);
expect(state.roles.get("user")?.permissions).not.toContain("docker.run");
expect(state.appliedDefaults.has("user:docker.run")).toBe(false);
// Next boot, the database is fine again.
await loader.shutdown();
state.failUpdate = false;
resetPluginRuntime();
const again = getPluginRuntime().loader;
await activatePlugin((await again.load(fixture.dir)).id);
expect(state.roles.get("user")?.permissions).toContain("docker.run");
expect(state.appliedDefaults.has("user:docker.run")).toBe(true);
await again.shutdown();
} finally {
fixture.cleanup();
}
});
it("only ever land in the plugin's own namespace", async () => {
state.roles.set("user", { id: 2, permissions: [] });
const fixture = createFixturePlugin({
id: "docker",
capabilities: ["kv:own"],
manifestOverrides: {
category: "Infrastructure",
contributes: { permissions: [permission("run", ["user"])] },
},
});
const { resetPluginRuntime, getPluginRuntime, activatePlugin } =
await import("../../../plugins/index.js");
resetPluginRuntime();
const loader = getPluginRuntime().loader;
try {
await activatePlugin((await loader.load(fixture.dir)).id);
expect(state.roles.get("user")?.permissions).toEqual(["docker.run"]);
} finally {
await loader.shutdown();
fixture.cleanup();
}
});
});
@@ -0,0 +1,268 @@
/**
* /plugin-api and /plugin-ws: auth unless declared public, 503 for an
* installed plugin that is off, no stack in an error, public routes listed
* for admins, and an anonymous caller who cannot spend a plugin's error
* budget.
*/
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import express from "express";
import httpModule from "node:http";
import type { AddressInfo } from "node:net";
import type { Server } from "node:http";
import { WebSocket } from "ws";
const state = vi.hoisted(() => ({
validToken: "valid-token",
userId: "user-1",
installed: new Set<string>(),
audits: [] as Array<Record<string, unknown>>,
}));
vi.mock("../../../utils/logger.js", () => {
const log = {
debug: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
success: vi.fn(),
};
return { pluginLogger: log, databaseLogger: log };
});
vi.mock("../../../utils/auth-manager.js", () => ({
AuthManager: {
getInstance: () => ({
createAuthMiddleware:
() =>
(
req: express.Request,
res: express.Response,
next: express.NextFunction,
) => {
const header = req.headers.authorization;
if (header !== `Bearer ${state.validToken}`) {
res.status(401).json({ error: "Unauthorized" });
return;
}
(req as express.Request & { userId?: string }).userId = state.userId;
next();
},
verifyJWTToken: async (token: string) =>
token === state.validToken ? { userId: state.userId } : null,
}),
},
}));
vi.mock("../../../utils/data-crypto.js", () => ({
DataCrypto: { getUserDataKey: () => Buffer.from("key") },
}));
vi.mock("../../../plugins/permissions.js", () => ({
hasCapability: async () => true,
}));
vi.mock("../../../utils/audit-logger.js", () => ({
logAudit: async (entry: Record<string, unknown>) => {
state.audits.push(entry);
},
}));
const http = await import("../../../plugins/http.js");
const ws = await import("../../../plugins/ws.js");
const { getActor } = await import("../../../plugins/actor.js");
const { mountPluginApi } =
await import("../../../database/routes/plugin-api-routes.js");
const manifest = {
id: "sample",
name: "Sample",
version: "1.0.0",
capabilities: ["network:serve"],
} as never;
let server: Server | null = null;
async function start(): Promise<{ httpBase: string; wsBase: string }> {
const app = express();
mountPluginApi(app);
const instance = httpModule.createServer(app);
ws.attachPluginWebSockets(instance);
server = instance;
return new Promise((resolve) => {
instance.listen(0, "127.0.0.1", () => {
const { port } = instance.address() as AddressInfo;
resolve({
httpBase: `http://127.0.0.1:${port}`,
wsBase: `ws://127.0.0.1:${port}`,
});
});
});
}
function upgrade(
url: string,
protocols: string[] = [],
): Promise<"open" | number> {
return new Promise((resolve) => {
const socket = new WebSocket(url, protocols);
socket.once("open", () => {
socket.close();
resolve("open");
});
socket.once("unexpected-response", (_req, res) =>
resolve(res.statusCode ?? 0),
);
socket.once("error", () => resolve(0));
});
}
beforeEach(() => {
http.resetPluginHttp();
ws.resetPluginWebSockets();
state.installed = new Set();
state.audits = [];
http.setPluginInstalledCheck((id) => state.installed.has(id));
});
afterEach(async () => {
ws.resetPluginWebSockets();
if (server) {
await new Promise((resolve) => server!.close(() => resolve(undefined)));
server = null;
}
});
describe("auth by default", () => {
it("refuses an HTTP route and a socket without a login", async () => {
const router = http.createPluginRouter({ manifest, reportError: () => {} });
router.get("/private", (_req, res) => res.json({ ok: true }));
ws.registerPluginWsRoute("sample", "/socket", () => {}, ["network:serve"]);
const { httpBase, wsBase } = await start();
expect((await fetch(`${httpBase}/plugin-api/sample/private`)).status).toBe(
401,
);
expect(await upgrade(`${wsBase}/plugin-ws/sample/socket`)).toBe(401);
});
it("gives a public socket's guest no actor at all", async () => {
let actor: string | undefined = "unset";
ws.registerPluginWsRoute(
"sample",
"/guest",
({ socket }) => {
actor = getActor();
(socket as WebSocket).close();
},
["network:serve"],
{ public: true },
);
const { wsBase } = await start();
await upgrade(`${wsBase}/plugin-ws/sample/guest`);
await new Promise((resolve) => setTimeout(resolve, 20));
expect(actor).toBeUndefined();
});
});
describe("a disabled plugin", () => {
it("answers 503 on HTTP and on a socket once its routes are gone", async () => {
state.installed.add("sample");
const { httpBase, wsBase } = await start();
const response = await fetch(`${httpBase}/plugin-api/sample/anything`);
expect(response.status).toBe(503);
expect(await upgrade(`${wsBase}/plugin-ws/sample/socket`)).toBe(503);
});
it("answers 404 for an id nobody installed", async () => {
const { httpBase, wsBase } = await start();
expect((await fetch(`${httpBase}/plugin-api/ghost/x`)).status).toBe(404);
expect(await upgrade(`${wsBase}/plugin-ws/ghost/socket`)).toBe(404);
});
});
describe("errors", () => {
it("hides the stack and the message", async () => {
const router = http.createPluginRouter({ manifest, reportError: () => {} });
router.get("/boom", () => {
throw new Error("secret detail at /srv/termix/keys");
});
const { httpBase } = await start();
const response = await fetch(`${httpBase}/plugin-api/sample/boom`, {
headers: { Authorization: `Bearer ${state.validToken}` },
});
const body = await response.text();
expect(response.status).toBe(500);
expect(body).not.toMatch(/secret detail|at \//);
});
it("does not let an anonymous caller spend the error budget", async () => {
const reported: unknown[] = [];
const router = http.createPluginRouter({
manifest,
options: { public: ["/hook", "/throws"] },
reportError: (error) => reported.push(error),
});
router.post("/hook", (_req, res) => res.json({ ok: true }));
router.get("/throws", () => {
throw new Error("bad input");
});
const { httpBase } = await start();
for (let i = 0; i < 6; i++) {
const junk = await fetch(`${httpBase}/plugin-api/sample/hook`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: "{not json",
});
expect(junk.status).toBe(400);
await fetch(`${httpBase}/plugin-api/sample/throws`);
}
expect(reported).toEqual([]);
});
it("still counts a signed-in caller's server error", async () => {
const reported: unknown[] = [];
const router = http.createPluginRouter({
manifest,
reportError: (error) => reported.push(error),
});
router.get("/boom", () => {
throw new Error("real bug");
});
const { httpBase } = await start();
await fetch(`${httpBase}/plugin-api/sample/boom`, {
headers: { Authorization: `Bearer ${state.validToken}` },
});
expect(reported).toHaveLength(1);
});
});
describe("public routes are visible", () => {
it("lists them for the admin details and audits a public socket", async () => {
http.createPluginRouter({
manifest,
options: { public: ["/callback", "/u/:handle"] },
reportError: () => {},
});
ws.registerPluginWsRoute("sample", "/guest", () => {}, ["network:serve"], {
public: true,
});
ws.registerPluginWsRoute("sample", "/private", () => {}, ["network:serve"]);
await new Promise((resolve) => setTimeout(resolve, 0));
expect(http.getPluginPublicHttpRoutes("sample")).toEqual([
"/callback",
"/u/:handle",
]);
expect(ws.getPluginPublicWsRoutes("sample")).toEqual(["/guest"]);
expect(
state.audits.some(
(entry) =>
entry.action === "plugin_ws_public_route" &&
entry.resourceId === "sample",
),
).toBe(true);
http.unregisterPluginHttp("sample");
expect(http.getPluginPublicHttpRoutes("sample")).toEqual([]);
});
});
@@ -0,0 +1,154 @@
/**
* Settings: a secret is encrypted at rest and never read back, and every
* write path goes through the same checks, including the host import path
* that used to write rows straight into plugin_settings.
*
* The HTTP scope checks (admin needs admin.plugins.manage, a user only ever
* writes their own, a host needs edit access) are covered route by route in
* settings-routes.test.ts.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
interface Row {
pluginId: string;
scope: string;
scopeId: string | null;
key: string;
value: string | null;
encrypted: boolean;
}
const state = vi.hoisted(() => ({
rows: [] as Row[],
manifests: [] as unknown[],
normalizer: null as null | ((raw: Record<string, unknown>) => unknown),
}));
vi.mock("../../../utils/logger.js", () => {
const log = { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() };
return { sshLogger: log, pluginLogger: log };
});
vi.mock("../../../database/repositories/factory.js", () => ({
createCurrentPluginSettingsRepository: () => ({
get: async (p: string, s: string, id: string | null, k: string) =>
state.rows.find(
(row) =>
row.pluginId === p &&
row.scope === s &&
row.scopeId === id &&
row.key === k,
) ?? null,
getAll: async (p: string, s: string, id: string | null) =>
state.rows.filter(
(row) => row.pluginId === p && row.scope === s && row.scopeId === id,
),
set: async (
pluginId: string,
scope: string,
scopeId: string | null,
key: string,
value: string | null,
encrypted = false,
) => {
state.rows = state.rows.filter(
(row) =>
!(
row.pluginId === pluginId &&
row.scope === scope &&
row.scopeId === scopeId &&
row.key === key
),
);
state.rows.push({ pluginId, scope, scopeId, key, value, encrypted });
},
}),
}));
vi.mock("../../../utils/system-secret-crypto.js", () => ({
isSystemEncrypted: (value: string) => value.startsWith("sysenc:v1:"),
encryptSystemSecret: async (plaintext: string) =>
`sysenc:v1:${Buffer.from(plaintext, "utf8").toString("base64")}`,
decryptSystemSecret: async (stored: string) =>
Buffer.from(stored.slice("sysenc:v1:".length), "base64").toString("utf8"),
}));
vi.mock("../../../plugins/index.js", () => ({
getPluginRuntime: () => ({
loader: {
list: () =>
state.manifests.map((manifest) => ({ state: "active", manifest })),
},
}),
}));
vi.mock("../../../plugins/registry.js", () => ({
consume: () => state.normalizer,
}));
const { applyPluginHostImportSettings } =
await import("../../../database/routes/host-plugin-settings.js");
const { getAllSettings, setSetting } =
await import("../../../plugins/settings.js");
const MANIFEST = {
id: "vaultish",
name: "Vaultish",
version: "1.0.0",
capabilities: [],
contributes: {
settings: {
host: {
fields: [
{ key: "token", type: "secret", labelKey: "k" },
{ key: "port", type: "number", labelKey: "k", min: 1, max: 65535 },
],
},
admin: [{ key: "apiKey", type: "secret", labelKey: "k" }],
},
},
};
beforeEach(() => {
state.rows = [];
state.manifests = [MANIFEST];
state.normalizer = null;
});
describe("secrets at rest", () => {
it("are stored encrypted and read back only as { set }", async () => {
await setSetting(MANIFEST as never, "admin", null, "apiKey", "hunter2");
const row = state.rows.find((entry) => entry.key === "apiKey")!;
expect(row.encrypted).toBe(true);
expect(row.value).not.toContain("hunter2");
const shown = await getAllSettings(MANIFEST as never, "admin", null, {
redactSecrets: true,
});
expect(shown.apiKey).toEqual({ set: true });
});
});
describe("the host import path", () => {
it("encrypts a secret a plugin's normalizer returns", async () => {
state.normalizer = () => ({ token: "from-import", port: 8200 });
await applyPluginHostImportSettings(7, {});
const token = state.rows.find((entry) => entry.key === "token")!;
expect(token.encrypted).toBe(true);
expect(token.value).not.toContain("from-import");
expect(state.rows.find((entry) => entry.key === "port")?.value).toBe(
"8200",
);
});
it("refuses a key the manifest does not declare, and a bad value", async () => {
state.normalizer = () => ({ injected: "x", port: 999999 });
await applyPluginHostImportSettings(7, {});
expect(state.rows).toEqual([]);
});
it("never restores a secret carried in an export", async () => {
await applyPluginHostImportSettings(7, {
pluginSettings: { vaultish: { token: "leaked", port: 22 } },
});
expect(state.rows.map((entry) => entry.key)).toEqual(["port"]);
});
});
+2
View File
@@ -238,6 +238,8 @@ export interface PluginSummary {
grantedCapabilities?: string[];
/** Why the plugin is blocked or failed. Admins only. */
lastError?: string | null;
/** Paths a running plugin serves without login. Admins only. */
publicRoutes?: { http: string[]; ws: string[] };
}
export async function getPlugins(): Promise<PluginSummary[]> {
+2
View File
@@ -1842,6 +1842,8 @@
"pluginDisabled": "{{name}} disabled",
"pluginPermissions": "Permissions",
"pluginPermissionsNone": "This plugin does not declare any capabilities that need to be granted.",
"pluginPublicRoutes": "Public routes",
"pluginPublicRoutesDescription": "Anyone can reach these without signing in. The plugin checks access on its own.",
"pluginPermissionGranted": "Granted",
"pluginPermissionNotGranted": "Not granted",
"pluginGrantFailed": "Failed to grant the capability",
+19 -6
View File
@@ -47,7 +47,7 @@ import { pluginHostBridge, resolvePluginPermission } from "./bridge";
import { shell, tabsApi } from "./shell-bridge";
import { onRemoteServerChange, remoteServerUrl } from "./desktop";
import { isElectron } from "@/lib/electron";
import { withPluginScope } from "./scope";
import { withPluginScope, withIconBoundary, guardCallback } from "./scope";
import { manifestDeclares, type ViewKind } from "./view-ownership";
import { pluginKey } from "@/lib/plugin-i18n";
import { hasPermission } from "@/hooks/use-permissions";
@@ -114,7 +114,10 @@ export function createPluginApp(
return track(
registerRailItem({
id: item.id,
icon: item.icon as unknown as LucideIcon,
icon: withIconBoundary(
pluginId,
item.icon as unknown as LucideIcon,
) as unknown as LucideIcon,
labelKey: key(item.titleKey),
kind: item.kind === "tab" ? "tab" : undefined,
hideable: item.hideable,
@@ -158,7 +161,7 @@ export function createPluginApp(
component: scoped(
component as unknown as ComponentType<TabRenderProps>,
),
icon: options.icon,
icon: withIconBoundary(pluginId, options.icon as never) as never,
titleKey: options.titleKey ? key(options.titleKey) : undefined,
requiresHost: options.requiresHost,
noHostMessageKey: options.noHostMessageKey
@@ -191,7 +194,7 @@ export function createPluginApp(
pluginId,
group: section.group,
labelKey: key(section.titleKey),
icon: section.icon,
icon: withIconBoundary(pluginId, section.icon as never) as never,
order: section.order,
visible: section.visible,
component: scoped(
@@ -207,10 +210,20 @@ export function createPluginApp(
...action,
titleKey: key(action.titleKey),
pluginId,
icon: withIconBoundary(pluginId, action.icon as never) as never,
when: action.when as never,
run: action.run as never,
label: action.label as never,
items: action.items as never,
// Called while the host list renders, so a throw must not escape.
label: guardCallback(
pluginId,
action.label as ((...args: unknown[]) => unknown) | undefined,
undefined,
) as never,
items: guardCallback(
pluginId,
action.items as ((...args: unknown[]) => unknown) | undefined,
undefined,
) as never,
}),
);
},
+21 -1
View File
@@ -184,6 +184,20 @@ async function deactivate(pluginId: string): Promise<void> {
setFrontendState(pluginId, "inactive");
}
const ACTIVATE_TIMEOUT_MS = 15_000;
function withTimeout<T>(
promise: Promise<T>,
ms: number,
message: string,
): Promise<T> {
let timer: ReturnType<typeof setTimeout> | undefined;
const timeout = new Promise<never>((_, reject) => {
timer = setTimeout(() => reject(new Error(message)), ms);
});
return Promise.race([promise, timeout]).finally(() => clearTimeout(timer));
}
async function activate(summary: PluginSummary): Promise<void> {
const loader = currentDeps();
setFrontendState(summary.id, "loading");
@@ -202,7 +216,13 @@ async function activate(summary: PluginSummary): Promise<void> {
summary.contributes,
{ guest: guestMode },
);
await module.activate(handle.app);
// A plugin whose activate never settles would hold up every plugin
// queued after it, so it fails instead.
await withTimeout(
Promise.resolve(module.activate(handle.app)),
ACTIVATE_TIMEOUT_MS,
`activate() did not finish within ${ACTIVATE_TIMEOUT_MS / 1000}s`,
);
active.set(summary.id, {
id: summary.id,
assetVersion: summary.assetVersion ?? null,
+58
View File
@@ -110,3 +110,61 @@ export function withPluginScope<P extends object>(
Scoped.displayName = `Plugin(${pluginId}:${Inner.displayName ?? Inner.name ?? "Component"})`;
return Scoped;
}
/** Renders nothing when a plugin's icon throws, instead of a crash notice. */
class IconBoundary extends Component<BoundaryProps, { error: boolean }> {
state = { error: false };
static getDerivedStateFromError() {
return { error: true };
}
componentDidCatch(error: Error) {
console.error(`[plugins] ${this.props.pluginId} icon failed`, error);
}
render() {
return this.state.error ? null : this.props.children;
}
}
/**
* A plugin icon the shell renders inline (rail, tab strip, host row). Small
* enough that a broken one should just disappear, not take the row with it.
*/
export function withIconBoundary<P extends object>(
pluginId: string,
Icon: ComponentType<P> | undefined,
): ComponentType<P> | undefined {
if (!Icon) return undefined;
function SafeIcon(props: P) {
return (
<IconBoundary pluginId={pluginId}>
<Icon {...props} />
</IconBoundary>
);
}
SafeIcon.displayName = `PluginIcon(${pluginId})`;
return SafeIcon;
}
/**
* A plugin callback core calls while rendering, such as a host action's
* label. A throw is logged and answered with `fallback`, so one plugin
* cannot break the host list.
*/
export function guardCallback<A extends unknown[], R>(
pluginId: string,
fn: ((...args: A) => R) | undefined,
fallback: R,
): ((...args: A) => R) | undefined {
if (!fn) return undefined;
return (...args: A) => {
try {
return fn(...args);
} catch (error) {
console.error(`[plugins] ${pluginId} callback failed`, error);
return fallback;
}
};
}
@@ -49,6 +49,14 @@ export function PluginPermissionsDialog({
declared.includes(row.capability),
);
const granted = new Set(plugin.grantedCapabilities ?? []);
const publicRoutes = [
...(plugin.publicRoutes?.http ?? []).map(
(path) => `/plugin-api/${plugin.id}${path}`,
),
...(plugin.publicRoutes?.ws ?? []).map(
(path) => `/plugin-ws/${plugin.id}${path}`,
),
];
const toggle = async (capability: string, isGranted: boolean) => {
setBusy(capability);
@@ -123,6 +131,27 @@ export function PluginPermissionsDialog({
</div>
)}
</div>
{publicRoutes.length > 0 && (
<div className="pb-3">
<p className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground mb-1">
{t("admin.pluginPublicRoutes")}
</p>
<p className="text-[11px] text-muted-foreground leading-snug mb-2">
{t("admin.pluginPublicRoutesDescription")}
</p>
<div className="border border-border">
{publicRoutes.map((route) => (
<div
key={route}
className="px-3 py-1.5 text-[11px] font-mono break-all border-b border-border last:border-b-0"
>
{route}
</div>
))}
</div>
</div>
)}
</DialogContent>
</Dialog>
);
@@ -0,0 +1,144 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import { Puzzle } from "lucide-react";
import type { FrontendModule } from "@termix/plugin-sdk/frontend";
import type { PluginSummary } from "@/api/plugins-api";
import {
configurePluginLoader,
resetPluginLoader,
syncPlugins,
} from "@/plugin-host/loader";
import { getPluginRecord, resetPluginStore } from "@/plugin-host/plugin-store";
import { guardCallback, withIconBoundary } from "@/plugin-host/scope";
import {
resetRegisteredRailItems,
visibleRailItems,
} from "@/sidebar/rail-items";
import {
listHostActions,
resetHostContributions,
} from "@/sidebar/host-contributions";
function summary(id: string): PluginSummary {
return {
id,
name: id,
version: "1.0.0",
enabled: true,
state: "active",
frontend: true,
assetVersion: "v1",
contributes: { panels: [{ id: `${id}-panel`, titleKey: "nav.hosts" }] },
};
}
let plugins: PluginSummary[] = [];
let modules: Record<string, FrontendModule> = {};
beforeEach(() => {
plugins = [];
modules = {};
vi.spyOn(console, "error").mockImplementation(() => {});
configurePluginLoader({
fetchPlugins: async () => plugins,
importFrontend: async (entry) => modules[entry.id],
loadLocale: async () => null,
injectCss: () => null,
});
});
afterEach(async () => {
vi.useRealTimers();
await resetPluginLoader();
resetPluginStore();
resetRegisteredRailItems();
resetHostContributions();
vi.restoreAllMocks();
});
describe("a plugin cannot stall or crash the shell", () => {
it("fails a plugin whose activate never finishes, and starts the rest", async () => {
vi.useFakeTimers();
modules.stuck = { activate: () => new Promise(() => {}) };
modules.healthy = {
activate(app) {
app.registerRailItem({
id: "healthy-panel",
icon: Puzzle,
titleKey: "nav.hosts",
});
},
};
plugins = [summary("stuck"), summary("healthy")];
const sync = syncPlugins();
await vi.advanceTimersByTimeAsync(16_000);
await sync;
expect(getPluginRecord("stuck")?.frontend).toBe("failed");
expect(visibleRailItems().map((item) => item.id)).toContain(
"healthy-panel",
);
});
it("answers a host action's throwing label and items with nothing", async () => {
modules.labels = {
activate(app) {
app.registerHostAction({
id: "labels.open",
titleKey: "open",
label: () => {
throw new Error("label broke");
},
items: () => {
throw new Error("items broke");
},
run: () => {},
} as never);
},
};
plugins = [summary("labels")];
await syncPlugins();
const action = listHostActions().find(
(entry) => entry.id === "labels.open",
);
expect(action).toBeDefined();
expect(() => action!.label?.({} as never)).not.toThrow();
expect(action!.label?.({} as never)).toBeUndefined();
expect(action!.items?.({} as never)).toBeUndefined();
});
});
describe("withIconBoundary", () => {
it("renders nothing for a throwing icon and leaves its neighbours alone", () => {
const Broken = () => {
throw new Error("icon broke");
};
const Safe = withIconBoundary("demo", Broken)!;
render(
<div>
<Safe />
<span>still here</span>
</div>,
);
expect(screen.getByText("still here")).toBeTruthy();
});
it("passes undefined through", () => {
expect(withIconBoundary("demo", undefined)).toBeUndefined();
});
});
describe("guardCallback", () => {
it("returns the fallback when the plugin's callback throws", () => {
const guarded = guardCallback(
"demo",
() => {
throw new Error("nope");
},
"fallback",
);
expect(guarded?.()).toBe("fallback");
});
});