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

+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()]) {