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