fix: harden the plugin runtime after the 2.9.0 security review

This commit is contained in:
LukeGus committed 2026-09-25 08:48:54 -05:00
1 parent 2961297989
commit c6a44b7ff3
69 files changed
+4613 -312

No files matched your search

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