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