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

+2
View File
@@ -238,6 +238,8 @@ export interface PluginSummary {
grantedCapabilities?: string[];
/** Why the plugin is blocked or failed. Admins only. */
lastError?: string | null;
/** Paths a running plugin serves without login. Admins only. */
publicRoutes?: { http: string[]; ws: string[] };
}
export async function getPlugins(): Promise<PluginSummary[]> {
+2
View File
@@ -1842,6 +1842,8 @@
"pluginDisabled": "{{name}} disabled",
"pluginPermissions": "Permissions",
"pluginPermissionsNone": "This plugin does not declare any capabilities that need to be granted.",
"pluginPublicRoutes": "Public routes",
"pluginPublicRoutesDescription": "Anyone can reach these without signing in. The plugin checks access on its own.",
"pluginPermissionGranted": "Granted",
"pluginPermissionNotGranted": "Not granted",
"pluginGrantFailed": "Failed to grant the capability",
+19 -6
View File
@@ -47,7 +47,7 @@ import { pluginHostBridge, resolvePluginPermission } from "./bridge";
import { shell, tabsApi } from "./shell-bridge";
import { onRemoteServerChange, remoteServerUrl } from "./desktop";
import { isElectron } from "@/lib/electron";
import { withPluginScope } from "./scope";
import { withPluginScope, withIconBoundary, guardCallback } from "./scope";
import { manifestDeclares, type ViewKind } from "./view-ownership";
import { pluginKey } from "@/lib/plugin-i18n";
import { hasPermission } from "@/hooks/use-permissions";
@@ -114,7 +114,10 @@ export function createPluginApp(
return track(
registerRailItem({
id: item.id,
icon: item.icon as unknown as LucideIcon,
icon: withIconBoundary(
pluginId,
item.icon as unknown as LucideIcon,
) as unknown as LucideIcon,
labelKey: key(item.titleKey),
kind: item.kind === "tab" ? "tab" : undefined,
hideable: item.hideable,
@@ -158,7 +161,7 @@ export function createPluginApp(
component: scoped(
component as unknown as ComponentType<TabRenderProps>,
),
icon: options.icon,
icon: withIconBoundary(pluginId, options.icon as never) as never,
titleKey: options.titleKey ? key(options.titleKey) : undefined,
requiresHost: options.requiresHost,
noHostMessageKey: options.noHostMessageKey
@@ -191,7 +194,7 @@ export function createPluginApp(
pluginId,
group: section.group,
labelKey: key(section.titleKey),
icon: section.icon,
icon: withIconBoundary(pluginId, section.icon as never) as never,
order: section.order,
visible: section.visible,
component: scoped(
@@ -207,10 +210,20 @@ export function createPluginApp(
...action,
titleKey: key(action.titleKey),
pluginId,
icon: withIconBoundary(pluginId, action.icon as never) as never,
when: action.when as never,
run: action.run as never,
label: action.label as never,
items: action.items as never,
// Called while the host list renders, so a throw must not escape.
label: guardCallback(
pluginId,
action.label as ((...args: unknown[]) => unknown) | undefined,
undefined,
) as never,
items: guardCallback(
pluginId,
action.items as ((...args: unknown[]) => unknown) | undefined,
undefined,
) as never,
}),
);
},
+21 -1
View File
@@ -184,6 +184,20 @@ async function deactivate(pluginId: string): Promise<void> {
setFrontendState(pluginId, "inactive");
}
const ACTIVATE_TIMEOUT_MS = 15_000;
function withTimeout<T>(
promise: Promise<T>,
ms: number,
message: string,
): Promise<T> {
let timer: ReturnType<typeof setTimeout> | undefined;
const timeout = new Promise<never>((_, reject) => {
timer = setTimeout(() => reject(new Error(message)), ms);
});
return Promise.race([promise, timeout]).finally(() => clearTimeout(timer));
}
async function activate(summary: PluginSummary): Promise<void> {
const loader = currentDeps();
setFrontendState(summary.id, "loading");
@@ -202,7 +216,13 @@ async function activate(summary: PluginSummary): Promise<void> {
summary.contributes,
{ guest: guestMode },
);
await module.activate(handle.app);
// A plugin whose activate never settles would hold up every plugin
// queued after it, so it fails instead.
await withTimeout(
Promise.resolve(module.activate(handle.app)),
ACTIVATE_TIMEOUT_MS,
`activate() did not finish within ${ACTIVATE_TIMEOUT_MS / 1000}s`,
);
active.set(summary.id, {
id: summary.id,
assetVersion: summary.assetVersion ?? null,
+58
View File
@@ -110,3 +110,61 @@ export function withPluginScope<P extends object>(
Scoped.displayName = `Plugin(${pluginId}:${Inner.displayName ?? Inner.name ?? "Component"})`;
return Scoped;
}
/** Renders nothing when a plugin's icon throws, instead of a crash notice. */
class IconBoundary extends Component<BoundaryProps, { error: boolean }> {
state = { error: false };
static getDerivedStateFromError() {
return { error: true };
}
componentDidCatch(error: Error) {
console.error(`[plugins] ${this.props.pluginId} icon failed`, error);
}
render() {
return this.state.error ? null : this.props.children;
}
}
/**
* A plugin icon the shell renders inline (rail, tab strip, host row). Small
* enough that a broken one should just disappear, not take the row with it.
*/
export function withIconBoundary<P extends object>(
pluginId: string,
Icon: ComponentType<P> | undefined,
): ComponentType<P> | undefined {
if (!Icon) return undefined;
function SafeIcon(props: P) {
return (
<IconBoundary pluginId={pluginId}>
<Icon {...props} />
</IconBoundary>
);
}
SafeIcon.displayName = `PluginIcon(${pluginId})`;
return SafeIcon;
}
/**
* A plugin callback core calls while rendering, such as a host action's
* label. A throw is logged and answered with `fallback`, so one plugin
* cannot break the host list.
*/
export function guardCallback<A extends unknown[], R>(
pluginId: string,
fn: ((...args: A) => R) | undefined,
fallback: R,
): ((...args: A) => R) | undefined {
if (!fn) return undefined;
return (...args: A) => {
try {
return fn(...args);
} catch (error) {
console.error(`[plugins] ${pluginId} callback failed`, error);
return fallback;
}
};
}
@@ -49,6 +49,14 @@ export function PluginPermissionsDialog({
declared.includes(row.capability),
);
const granted = new Set(plugin.grantedCapabilities ?? []);
const publicRoutes = [
...(plugin.publicRoutes?.http ?? []).map(
(path) => `/plugin-api/${plugin.id}${path}`,
),
...(plugin.publicRoutes?.ws ?? []).map(
(path) => `/plugin-ws/${plugin.id}${path}`,
),
];
const toggle = async (capability: string, isGranted: boolean) => {
setBusy(capability);
@@ -123,6 +131,27 @@ export function PluginPermissionsDialog({
</div>
)}
</div>
{publicRoutes.length > 0 && (
<div className="pb-3">
<p className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground mb-1">
{t("admin.pluginPublicRoutes")}
</p>
<p className="text-[11px] text-muted-foreground leading-snug mb-2">
{t("admin.pluginPublicRoutesDescription")}
</p>
<div className="border border-border">
{publicRoutes.map((route) => (
<div
key={route}
className="px-3 py-1.5 text-[11px] font-mono break-all border-b border-border last:border-b-0"
>
{route}
</div>
))}
</div>
</div>
)}
</DialogContent>
</Dialog>
);
@@ -0,0 +1,144 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import { Puzzle } from "lucide-react";
import type { FrontendModule } from "@termix/plugin-sdk/frontend";
import type { PluginSummary } from "@/api/plugins-api";
import {
configurePluginLoader,
resetPluginLoader,
syncPlugins,
} from "@/plugin-host/loader";
import { getPluginRecord, resetPluginStore } from "@/plugin-host/plugin-store";
import { guardCallback, withIconBoundary } from "@/plugin-host/scope";
import {
resetRegisteredRailItems,
visibleRailItems,
} from "@/sidebar/rail-items";
import {
listHostActions,
resetHostContributions,
} from "@/sidebar/host-contributions";
function summary(id: string): PluginSummary {
return {
id,
name: id,
version: "1.0.0",
enabled: true,
state: "active",
frontend: true,
assetVersion: "v1",
contributes: { panels: [{ id: `${id}-panel`, titleKey: "nav.hosts" }] },
};
}
let plugins: PluginSummary[] = [];
let modules: Record<string, FrontendModule> = {};
beforeEach(() => {
plugins = [];
modules = {};
vi.spyOn(console, "error").mockImplementation(() => {});
configurePluginLoader({
fetchPlugins: async () => plugins,
importFrontend: async (entry) => modules[entry.id],
loadLocale: async () => null,
injectCss: () => null,
});
});
afterEach(async () => {
vi.useRealTimers();
await resetPluginLoader();
resetPluginStore();
resetRegisteredRailItems();
resetHostContributions();
vi.restoreAllMocks();
});
describe("a plugin cannot stall or crash the shell", () => {
it("fails a plugin whose activate never finishes, and starts the rest", async () => {
vi.useFakeTimers();
modules.stuck = { activate: () => new Promise(() => {}) };
modules.healthy = {
activate(app) {
app.registerRailItem({
id: "healthy-panel",
icon: Puzzle,
titleKey: "nav.hosts",
});
},
};
plugins = [summary("stuck"), summary("healthy")];
const sync = syncPlugins();
await vi.advanceTimersByTimeAsync(16_000);
await sync;
expect(getPluginRecord("stuck")?.frontend).toBe("failed");
expect(visibleRailItems().map((item) => item.id)).toContain(
"healthy-panel",
);
});
it("answers a host action's throwing label and items with nothing", async () => {
modules.labels = {
activate(app) {
app.registerHostAction({
id: "labels.open",
titleKey: "open",
label: () => {
throw new Error("label broke");
},
items: () => {
throw new Error("items broke");
},
run: () => {},
} as never);
},
};
plugins = [summary("labels")];
await syncPlugins();
const action = listHostActions().find(
(entry) => entry.id === "labels.open",
);
expect(action).toBeDefined();
expect(() => action!.label?.({} as never)).not.toThrow();
expect(action!.label?.({} as never)).toBeUndefined();
expect(action!.items?.({} as never)).toBeUndefined();
});
});
describe("withIconBoundary", () => {
it("renders nothing for a throwing icon and leaves its neighbours alone", () => {
const Broken = () => {
throw new Error("icon broke");
};
const Safe = withIconBoundary("demo", Broken)!;
render(
<div>
<Safe />
<span>still here</span>
</div>,
);
expect(screen.getByText("still here")).toBeTruthy();
});
it("passes undefined through", () => {
expect(withIconBoundary("demo", undefined)).toBeUndefined();
});
});
describe("guardCallback", () => {
it("returns the fallback when the plugin's callback throws", () => {
const guarded = guardCallback(
"demo",
() => {
throw new Error("nope");
},
"fallback",
);
expect(guarded?.()).toBe("fallback");
});
});