Files
Termix/src/backend/tests/plugins/security/discovery.test.ts
T

178 lines
6.0 KiB
TypeScript

/**
* 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 { PluginLoader, prefixesOverlap } from "../../../plugins/loader.js";
import { parseManifest } from "../../../plugins/manifest.js";
import { RESERVED_PLUGIN_IDS } from "@termix-ssh/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"/);
});
});
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/);
});
});