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

@@ -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"]);
});
});