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

@@ -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 -4
View File
@@ -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",