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
@@ -225,21 +225,27 @@ export async function withHostPluginSettings(
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes a set of host-scope plugin settings in one call, for a core route
|
||||
* that still accepts a plugin's fields inline on the host create/update body
|
||||
* (the fields haven't grown their own editor UI flow yet). Values are
|
||||
* JSON-stringified the same way ctx.settings.setHost stores them.
|
||||
* Writes a set of host-scope plugin settings in one call, through the same
|
||||
* path as every other settings write: an undeclared key is refused, a value
|
||||
* is validated, and a secret field is encrypted.
|
||||
*/
|
||||
export async function writeHostPluginSettings(
|
||||
pluginId: string,
|
||||
manifest: PluginManifest,
|
||||
hostId: number,
|
||||
values: Record<string, unknown>,
|
||||
): Promise<void> {
|
||||
const repository = createCurrentPluginSettingsRepository();
|
||||
const scopeId = String(hostId);
|
||||
for (const [key, value] of Object.entries(values)) {
|
||||
if (value === undefined) continue;
|
||||
await repository.set(pluginId, "host", scopeId, key, JSON.stringify(value));
|
||||
const error = await setSetting(manifest, "host", hostId, key, value);
|
||||
if (error) {
|
||||
sshLogger.warn("Refused a plugin host setting on import", {
|
||||
operation: "host_import_plugin_settings",
|
||||
pluginId: manifest.id,
|
||||
hostId,
|
||||
key,
|
||||
error,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -296,7 +302,7 @@ export async function applyPluginHostImportSettings(
|
||||
`${manifest.id}.hostImportNormalizer`,
|
||||
);
|
||||
const values = normalizer?.(raw);
|
||||
if (values) await writeHostPluginSettings(manifest.id, hostId, values);
|
||||
if (values) await writeHostPluginSettings(manifest, hostId, values);
|
||||
} catch (error) {
|
||||
sshLogger.warn("Plugin host import normalizer failed", {
|
||||
operation: "host_import_plugin_settings",
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
|
||||
import express, { type Request, type Response } from "express";
|
||||
import { databaseLogger } from "../../utils/logger.js";
|
||||
import { getPluginRouter } from "../../plugins/http.js";
|
||||
import { getPluginRouter, isPluginInstalled } from "../../plugins/http.js";
|
||||
import { getRequestBasePath } from "../../utils/request-origin.js";
|
||||
import type { PluginLegacyRedirect } from "@termix/plugin-sdk/manifest";
|
||||
|
||||
@@ -51,7 +51,7 @@ const router = express.Router();
|
||||
* 401:
|
||||
* description: Authentication required.
|
||||
* 404:
|
||||
* description: Plugin not installed or serves no routes.
|
||||
* description: No plugin with this id is installed.
|
||||
* 503:
|
||||
* description: Plugin is installed but not running.
|
||||
*/
|
||||
@@ -60,17 +60,26 @@ router.use("/:pluginId", (req: Request, res: Response, next) => {
|
||||
const pluginRouter = getPluginRouter(pluginId);
|
||||
|
||||
if (!pluginRouter) {
|
||||
databaseLogger.warn("Plugin API request for unregistered plugin", {
|
||||
operation: "plugin_api_dispatch",
|
||||
pluginId,
|
||||
});
|
||||
res.status(404).json({ error: "Plugin not installed or not running" });
|
||||
notServing(res, pluginId);
|
||||
return;
|
||||
}
|
||||
|
||||
pluginRouter(req, res, next);
|
||||
});
|
||||
|
||||
/** 503 for an installed plugin that is off, 404 for an unknown id. */
|
||||
function notServing(res: Response, pluginId: string): void {
|
||||
if (isPluginInstalled(pluginId)) {
|
||||
res.status(503).json({ error: "Plugin is not running", pluginId });
|
||||
return;
|
||||
}
|
||||
databaseLogger.warn("Plugin API request for unregistered plugin", {
|
||||
operation: "plugin_api_dispatch",
|
||||
pluginId,
|
||||
});
|
||||
res.status(404).json({ error: "Plugin not installed" });
|
||||
}
|
||||
|
||||
/**
|
||||
* Mounts the dispatcher. No auth in front of it: each plugin router runs
|
||||
* core auth itself and skips it only for the paths it declared public, so a
|
||||
@@ -120,7 +129,7 @@ export function mountPluginLegacyPaths(
|
||||
if (!plugin.legacyPaths.some((path) => under(req.path, path))) continue;
|
||||
const pluginRouter = getPluginRouter(plugin.id);
|
||||
if (!pluginRouter) {
|
||||
res.status(404).json({ error: "Plugin not installed or not running" });
|
||||
notServing(res, plugin.id);
|
||||
return;
|
||||
}
|
||||
req.url = req.url.slice(plugin.id.length + 1) || "/";
|
||||
|
||||
@@ -16,6 +16,8 @@ import { getPluginRuntime } from "../../plugins/index.js";
|
||||
import { describePluginFrontend } from "../../plugins/assets.js";
|
||||
import { getSetting } from "../../plugins/settings.js";
|
||||
import { invalidatePluginPermissionCache } from "../../plugins/permissions.js";
|
||||
import { getPluginPublicHttpRoutes } from "../../plugins/http.js";
|
||||
import { getPluginPublicWsRoutes } from "../../plugins/ws.js";
|
||||
import {
|
||||
findField,
|
||||
getAllSettings,
|
||||
@@ -191,8 +193,10 @@ export async function listPreLoginPlugins(): Promise<
|
||||
*
|
||||
* Non-admins get only what the shell needs. What a plugin is allowed to
|
||||
* do, what has been granted to it and why it failed are operational
|
||||
* details, so capabilities, grants and lastError are included only for
|
||||
* holders of admin.plugins.manage.
|
||||
* details, so capabilities, grants, lastError and publicRoutes are
|
||||
* included only for holders of admin.plugins.manage. publicRoutes lists
|
||||
* the HTTP paths and socket paths a running plugin serves without
|
||||
* core's login check.
|
||||
*
|
||||
* The frontend fields drive the browser's plugin loader: `frontend`
|
||||
* says there is a bundle at /plugin-assets/<id>/frontend.js,
|
||||
@@ -283,6 +287,10 @@ router.get("/", authenticateJWT, async (req: Request, res: Response) => {
|
||||
capabilities,
|
||||
grantedCapabilities: grantsByPlugin.get(record.id) ?? [],
|
||||
lastError: loaded?.lastError ?? record.lastError ?? null,
|
||||
publicRoutes: {
|
||||
http: getPluginPublicHttpRoutes(record.id),
|
||||
ws: getPluginPublicWsRoutes(record.id),
|
||||
},
|
||||
};
|
||||
}),
|
||||
);
|
||||
@@ -640,7 +648,7 @@ router.delete(
|
||||
* /plugins/{id}/data:
|
||||
* delete:
|
||||
* summary: Delete everything a plugin stores
|
||||
* description: Drops the plugin's own tables and clears its key/value state, capability grants and migration ledger. Disabling a plugin never touches its data; this is the explicit uninstall path, and it cannot be undone. The plugin is deactivated first so nothing is writing while its tables go.
|
||||
* description: Drops every table under the plugin's prefix and clears its key/value state, settings and secrets, capability grants and migration ledger. Role permissions stay. Disabling a plugin never touches its data; this is the explicit uninstall path, and it cannot be undone. The plugin is deactivated first so nothing is writing while its tables go.
|
||||
* tags:
|
||||
* - Plugins
|
||||
* parameters:
|
||||
@@ -680,7 +688,11 @@ router.delete(
|
||||
}
|
||||
|
||||
const { removePluginData } = await import("../../plugins/data.js");
|
||||
const removed = await removePluginData(pluginId);
|
||||
const removed = await removePluginData(pluginId, {
|
||||
knownPluginIds: getPluginRuntime()
|
||||
.loader.list()
|
||||
.map((plugin) => plugin.id),
|
||||
});
|
||||
|
||||
databaseLogger.warn(`Removed all data for plugin ${pluginId}`, {
|
||||
operation: "plugin_remove_data",
|
||||
|
||||
Reference in new issue
Block a user