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
@@ -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,
|
||||
}),
|
||||
);
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user