mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-29 06:35: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
@@ -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"]);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user