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

+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;
}
};
}