mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-28 06:05: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
@@ -699,8 +699,11 @@ export interface PluginHostStatusEntry {
|
||||
* turns "reachable" into "online".
|
||||
*/
|
||||
export interface PluginHostStatusApi {
|
||||
/** The last known status, or null when core has not checked the host yet. */
|
||||
get: (hostId: number) => PluginHostStatusEntry | null;
|
||||
/**
|
||||
* The last known status, or null when core has not checked the host yet or
|
||||
* the acting user cannot reach it.
|
||||
*/
|
||||
get: (hostId: number) => Promise<PluginHostStatusEntry | null>;
|
||||
/** Checks the host now unless core checked it recently. */
|
||||
check: (hostId: number) => Promise<PluginHostStatusEntry | null>;
|
||||
/** Reports a login attempt. Not audited: pollers call it every sample. */
|
||||
@@ -1588,7 +1591,7 @@ export interface PluginCapabilities {
|
||||
}
|
||||
|
||||
export interface PluginProcessOptions {
|
||||
/** Added to the server's environment. */
|
||||
/** Added to a small base environment (PATH, HOME, temp, locale, proxy), never the whole server one. */
|
||||
env?: Record<string, string>;
|
||||
cwd?: string;
|
||||
/** Kills the program with SIGKILL after this long. */
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
* core's schema, kept deliberately in step with it.
|
||||
*/
|
||||
|
||||
import { KEY_LENGTH, prefixedTableName } from "./db.js";
|
||||
import { KEY_LENGTH, prefixedTableName, tablePrefix } from "./db.js";
|
||||
import type {
|
||||
PluginColumn,
|
||||
PluginTableDefinition,
|
||||
@@ -346,3 +346,253 @@ export function addColumnSql(
|
||||
|
||||
return `ALTER TABLE ${quote(dialect, physical)} ADD COLUMN ${line};`;
|
||||
}
|
||||
|
||||
interface SqlToken {
|
||||
/** As written: a bare word keeps its case, a quoted name loses its quotes. */
|
||||
value: string;
|
||||
kind: "word" | "quoted" | "punct";
|
||||
}
|
||||
|
||||
/**
|
||||
* Words and identifiers in one statement, with comments and string literals
|
||||
* dropped. `"x"`, `` `x` `` and `[x]` all come out as the identifier x, so no
|
||||
* quoting style hides a table name from the ownership check.
|
||||
*/
|
||||
function tokenize(source: string): SqlToken[] {
|
||||
const tokens: SqlToken[] = [];
|
||||
let i = 0;
|
||||
while (i < source.length) {
|
||||
const char = source[i];
|
||||
const next = source[i + 1];
|
||||
if (/\s/.test(char)) {
|
||||
i++;
|
||||
} else if (char === "-" && next === "-") {
|
||||
while (i < source.length && source[i] !== "\n") i++;
|
||||
} else if (char === "/" && next === "*") {
|
||||
const end = source.indexOf("*/", i + 2);
|
||||
i = end < 0 ? source.length : end + 2;
|
||||
} else if (char === "'") {
|
||||
i++;
|
||||
while (i < source.length) {
|
||||
if (source[i] === "'" && source[i + 1] === "'") i += 2;
|
||||
else if (source[i] === "'") break;
|
||||
else i++;
|
||||
}
|
||||
i++;
|
||||
tokens.push({ value: "'", kind: "punct" });
|
||||
} else if (char === '"' || char === "`" || char === "[") {
|
||||
const close = char === "[" ? "]" : char;
|
||||
let value = "";
|
||||
i++;
|
||||
while (i < source.length) {
|
||||
if (source[i] === close && source[i + 1] === close && close !== "]") {
|
||||
value += close;
|
||||
i += 2;
|
||||
} else if (source[i] === close) {
|
||||
break;
|
||||
} else {
|
||||
value += source[i++];
|
||||
}
|
||||
}
|
||||
i++;
|
||||
tokens.push({ value, kind: "quoted" });
|
||||
} else if (/[A-Za-z0-9_$]/.test(char)) {
|
||||
let value = "";
|
||||
while (i < source.length && /[A-Za-z0-9_$]/.test(source[i])) {
|
||||
value += source[i++];
|
||||
}
|
||||
tokens.push({ value, kind: "word" });
|
||||
} else {
|
||||
tokens.push({ value: char, kind: "punct" });
|
||||
i++;
|
||||
}
|
||||
}
|
||||
return tokens;
|
||||
}
|
||||
|
||||
/** Statements a plugin migration may never contain. */
|
||||
const REFUSED_STATEMENTS = new Set([
|
||||
"ATTACH",
|
||||
"DETACH",
|
||||
"PRAGMA",
|
||||
"GRANT",
|
||||
"REVOKE",
|
||||
"COPY",
|
||||
"LOAD",
|
||||
"DO",
|
||||
"CALL",
|
||||
"HANDLER",
|
||||
"WITH",
|
||||
"SET",
|
||||
"VACUUM",
|
||||
]);
|
||||
|
||||
/** What a migration may CREATE, ALTER or DROP. Anything else is refused. */
|
||||
const TABLE_OBJECTS = new Set(["TABLE", "VIEW"]);
|
||||
const HARMLESS_OBJECTS = new Set(["SEQUENCE", "TYPE"]);
|
||||
|
||||
const CREATE_MODIFIERS = [
|
||||
"OR",
|
||||
"REPLACE",
|
||||
"TEMP",
|
||||
"TEMPORARY",
|
||||
"UNLOGGED",
|
||||
"GLOBAL",
|
||||
"LOCAL",
|
||||
"VIRTUAL",
|
||||
"UNIQUE",
|
||||
"CONCURRENTLY",
|
||||
];
|
||||
|
||||
/**
|
||||
* Every problem with the tables a migration writes to.
|
||||
*
|
||||
* The check has to hold against a plugin that is trying, not only one that
|
||||
* made a typo, so it reads each statement the way the engine does rather
|
||||
* than matching one pattern: a comment between a keyword and a name, bracket
|
||||
* quoting, a second name in a DROP list, a schema-qualified name and
|
||||
* `ALTER ... RENAME TO users` are all seen. Reads (FROM, JOIN, REFERENCES)
|
||||
* are allowed; writes outside the plugin's prefix are not.
|
||||
*
|
||||
* `ownedLegacy` lists the legacy core tables this plugin adopts.
|
||||
*/
|
||||
export function findUnownedTableWrites(
|
||||
pluginId: string,
|
||||
sql: string,
|
||||
ownedLegacy: ReadonlySet<string> = new Set(),
|
||||
): string[] {
|
||||
const prefix = tablePrefix(pluginId);
|
||||
const problems: string[] = [];
|
||||
|
||||
for (const statement of splitStatements(sql)) {
|
||||
const tokens = tokenize(statement);
|
||||
if (tokens.length === 0) continue;
|
||||
const upper = (at: number) =>
|
||||
tokens[at]?.kind === "word" ? tokens[at].value.toUpperCase() : "";
|
||||
|
||||
/** Checks the table name at `at`, returns the index after it. */
|
||||
const table = (at: number): number => {
|
||||
const name = tokens[at];
|
||||
if (!name || name.kind === "punct") {
|
||||
problems.push("a statement writes to a table with no name");
|
||||
return at + 1;
|
||||
}
|
||||
if (tokens[at + 1]?.value === ".") {
|
||||
problems.push(
|
||||
`"${name.value}.${tokens[at + 2]?.value ?? ""}" names a schema; a plugin may only write its own tables`,
|
||||
);
|
||||
return at + 3;
|
||||
}
|
||||
if (!name.value.startsWith(prefix) && !ownedLegacy.has(name.value)) {
|
||||
problems.push(
|
||||
`writes to "${name.value}", which is not prefixed "${prefix}"`,
|
||||
);
|
||||
}
|
||||
return at + 1;
|
||||
};
|
||||
const skip = (at: number, words: string[]) => {
|
||||
while (words.includes(upper(at))) at++;
|
||||
return at;
|
||||
};
|
||||
const find = (word: string, from = 0) => {
|
||||
for (let at = from; at < tokens.length; at++) {
|
||||
if (upper(at) === word) return at;
|
||||
}
|
||||
return -1;
|
||||
};
|
||||
|
||||
const first = upper(0);
|
||||
if (REFUSED_STATEMENTS.has(first)) {
|
||||
problems.push(`${first} is not allowed in a plugin migration`);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (first === "CREATE" || first === "ALTER" || first === "DROP") {
|
||||
let at = skip(1, CREATE_MODIFIERS);
|
||||
const object = upper(at);
|
||||
if (object === "INDEX") {
|
||||
// The index name is free; the table after ON is what it writes to.
|
||||
const on = find("ON", at);
|
||||
if (on > 0) table(skip(on + 1, ["ONLY"]));
|
||||
continue;
|
||||
}
|
||||
if (HARMLESS_OBJECTS.has(object)) continue;
|
||||
if (!TABLE_OBJECTS.has(object)) {
|
||||
problems.push(
|
||||
`${first} ${object || tokens[at]?.value || ""} is not allowed in a plugin migration`.trim(),
|
||||
);
|
||||
continue;
|
||||
}
|
||||
at = table(skip(at + 1, ["IF", "NOT", "EXISTS", "ONLY"]));
|
||||
// DROP TABLE a, b drops both.
|
||||
while (first === "DROP" && tokens[at]?.value === ",") {
|
||||
at = table(at + 1);
|
||||
}
|
||||
// ALTER TABLE p_x RENAME TO users moves a table out of the namespace.
|
||||
// RENAME [COLUMN] a TO b renames a column and is fine.
|
||||
for (let rename = find("RENAME", at); rename > 0;) {
|
||||
const next = upper(rename + 1);
|
||||
if (next === "TO" || next === "AS") table(rename + 2);
|
||||
rename = find("RENAME", rename + 1);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (first === "RENAME") {
|
||||
// RENAME TABLE a TO b, c TO d
|
||||
let at = skip(1, ["TABLE"]);
|
||||
while (at < tokens.length) {
|
||||
at = table(at);
|
||||
if (upper(at) !== "TO") break;
|
||||
at = table(at + 1);
|
||||
if (tokens[at]?.value !== ",") break;
|
||||
at++;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (first === "TRUNCATE") {
|
||||
let at = table(skip(1, ["TABLE", "ONLY"]));
|
||||
while (tokens[at]?.value === ",") at = table(at + 1);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (first === "INSERT" || first === "REPLACE") {
|
||||
const into = find("INTO");
|
||||
if (into > 0) table(into + 1);
|
||||
else problems.push(`${first} without INTO is not allowed`);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (first === "UPDATE") {
|
||||
table(
|
||||
skip(1, [
|
||||
"OR",
|
||||
"ROLLBACK",
|
||||
"ABORT",
|
||||
"REPLACE",
|
||||
"FAIL",
|
||||
"IGNORE",
|
||||
"ONLY",
|
||||
"LOW_PRIORITY",
|
||||
]),
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (first === "DELETE") {
|
||||
const from = find("FROM");
|
||||
if (from > 0) table(skip(from + 1, ["ONLY"]));
|
||||
else problems.push("DELETE without FROM is not allowed");
|
||||
continue;
|
||||
}
|
||||
|
||||
if (first !== "SELECT" && first !== "COMMENT") {
|
||||
problems.push(
|
||||
`"${tokens[0].value}" statements are not allowed in a plugin migration`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return problems;
|
||||
}
|
||||
@@ -37,6 +37,27 @@ export type ActionContributionKind = (typeof ACTION_CONTRIBUTION_KINDS)[number];
|
||||
const OPEN_FROM_VALUES = ["rail", "host-context-menu", "palette"] as const;
|
||||
|
||||
const ID_PATTERN = /^[a-z][a-z0-9-]{1,39}$/;
|
||||
|
||||
/**
|
||||
* Ids no plugin may take. A permission is registered as <id>.<name>, so a
|
||||
* plugin called "admin" would mint admin.* permissions and could default them
|
||||
* onto the user role; the rest are names core or the runtime already uses.
|
||||
*/
|
||||
export const RESERVED_PLUGIN_IDS: readonly string[] = [
|
||||
"admin",
|
||||
"hosts",
|
||||
"credentials",
|
||||
"core",
|
||||
"termix",
|
||||
"plugin",
|
||||
"plugins",
|
||||
"users",
|
||||
"system",
|
||||
"sdk",
|
||||
];
|
||||
|
||||
/** A preset key is a plain property name, never a prototype one. */
|
||||
const PRESET_KEY_PATTERN = /^[a-zA-Z][a-zA-Z0-9_]{0,63}$/;
|
||||
const SEMVER_PATTERN = /^\d+\.\d+\.\d+(-[0-9A-Za-z-.]+)?(\+[0-9A-Za-z-.]+)?$/;
|
||||
const API_VERSION_PATTERN = /^[0-9]+$/;
|
||||
/** Service names are dotted, e.g. "ssh.transport". */
|
||||
@@ -476,6 +497,30 @@ function rejectUnknown(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A path inside the plugin folder. Absolute paths, ".." and backslashes are
|
||||
* refused so an entry point or asset folder cannot point outside the plugin.
|
||||
*/
|
||||
function requireRelativePath(
|
||||
value: string,
|
||||
where: string,
|
||||
errors: string[],
|
||||
): void {
|
||||
const segments = value.split("/");
|
||||
if (
|
||||
value.startsWith("/") ||
|
||||
/^[A-Za-z]:/.test(value) ||
|
||||
value.includes("\\") ||
|
||||
value.includes("\0") ||
|
||||
segments.includes("..") ||
|
||||
segments.every((segment) => segment === "" || segment === ".")
|
||||
) {
|
||||
errors.push(
|
||||
`Field "${where}" must be a relative path inside the plugin, got: ${JSON.stringify(value)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function requireString(
|
||||
value: unknown,
|
||||
where: string,
|
||||
@@ -507,6 +552,8 @@ export function validateManifest(manifest: unknown): string[] {
|
||||
errors.push(
|
||||
`Field "id" must match ${ID_PATTERN} (lowercase, starts with a letter, 2-40 chars), got: ${JSON.stringify(m.id)}`,
|
||||
);
|
||||
} else if (RESERVED_PLUGIN_IDS.includes(m.id)) {
|
||||
errors.push(`Field "id" may not be "${m.id}": that name is reserved`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -524,9 +571,11 @@ export function validateManifest(manifest: unknown): string[] {
|
||||
|
||||
if ("repository" in m) requireString(m.repository, "repository", errors);
|
||||
if ("icon" in m) requireString(m.icon, "icon", errors);
|
||||
if ("backend" in m) requireString(m.backend, "backend", errors);
|
||||
if ("frontend" in m) requireString(m.frontend, "frontend", errors);
|
||||
if ("locales" in m) requireString(m.locales, "locales", errors);
|
||||
for (const field of ["backend", "frontend", "locales"] as const) {
|
||||
if (field in m && requireString(m[field], field, errors)) {
|
||||
requireRelativePath(m[field] as string, field, errors);
|
||||
}
|
||||
}
|
||||
|
||||
validateAuthor(m.author, errors);
|
||||
validateEngine(m.engine, errors);
|
||||
@@ -870,6 +919,11 @@ function validateUiPresets(value: unknown, errors: string[]): void {
|
||||
continue;
|
||||
}
|
||||
for (const [key, entry] of Object.entries(preset)) {
|
||||
if (!PRESET_KEY_PATTERN.test(key)) {
|
||||
errors.push(
|
||||
`${where}.${level} has an invalid key ${JSON.stringify(key)}`,
|
||||
);
|
||||
}
|
||||
if (!isPresetValue(entry)) {
|
||||
errors.push(
|
||||
`${where}.${level}.${key} must be a boolean, number, string or string array`,
|
||||
|
||||
@@ -885,7 +885,7 @@ export function createFakeContext(
|
||||
activities.push({ hostId, type, hostName });
|
||||
},
|
||||
status: {
|
||||
get: (hostId) => hostStatuses.get(hostId) ?? null,
|
||||
get: async (hostId) => hostStatuses.get(hostId) ?? null,
|
||||
check: async (hostId) => hostStatuses.get(hostId) ?? null,
|
||||
reportLogin: (hostId, outcome) => {
|
||||
statusReports.push({ hostId, ...outcome });
|
||||
@@ -1546,7 +1546,7 @@ export function createMockCtx(
|
||||
return ctx.hosts.recordActivity(hostId, type, hostName);
|
||||
},
|
||||
status: {
|
||||
get: (hostId) => {
|
||||
get: async (hostId) => {
|
||||
require("hosts:read");
|
||||
return ctx.hosts.status.get(hostId);
|
||||
},
|
||||
|
||||
Reference in new issue
Block a user