Security: add explicit opt-in for deprecated plugin runtime exec (#20874)
Merged via /review-pr -> /prepare-pr -> /merge-pr. Prepared head SHA: de69f817259f0b4eba2d676005a9ed6833179ea2 Co-authored-by: mbelinky <132747814+mbelinky@users.noreply.github.com> Co-authored-by: mbelinky <132747814+mbelinky@users.noreply.github.com> Reviewed-by: @mbelinky
This commit is contained in:
@@ -1,13 +1,74 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const loadConfigMock = vi.hoisted(() => vi.fn());
|
||||
const runCommandWithTimeoutMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("../../config/config.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../../config/config.js")>();
|
||||
return {
|
||||
...actual,
|
||||
loadConfig: (...args: unknown[]) => loadConfigMock(...args),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../../process/exec.js", () => ({
|
||||
runCommandWithTimeout: (...args: unknown[]) => runCommandWithTimeoutMock(...args),
|
||||
}));
|
||||
|
||||
import { createPluginRuntime } from "./index.js";
|
||||
|
||||
describe("plugin runtime security hardening", () => {
|
||||
it("blocks runtime.system.runCommandWithTimeout", async () => {
|
||||
const blockedError =
|
||||
"runtime.system.runCommandWithTimeout is disabled for security hardening. Use fixed-purpose runtime APIs instead.";
|
||||
|
||||
beforeEach(() => {
|
||||
loadConfigMock.mockReset();
|
||||
runCommandWithTimeoutMock.mockReset();
|
||||
loadConfigMock.mockReturnValue({});
|
||||
});
|
||||
|
||||
it("blocks runtime.system.runCommandWithTimeout by default", async () => {
|
||||
const runtime = createPluginRuntime();
|
||||
await expect(
|
||||
runtime.system.runCommandWithTimeout(["echo", "hello"], { timeoutMs: 1000 }),
|
||||
).rejects.toThrow(
|
||||
"runtime.system.runCommandWithTimeout is disabled for security hardening. Use fixed-purpose runtime APIs instead.",
|
||||
);
|
||||
).rejects.toThrow(blockedError);
|
||||
expect(runCommandWithTimeoutMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("allows runtime.system.runCommandWithTimeout when explicitly opted in", async () => {
|
||||
loadConfigMock.mockReturnValue({
|
||||
plugins: {
|
||||
runtime: {
|
||||
allowLegacyExec: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
const commandResult = {
|
||||
stdout: "hello\n",
|
||||
stderr: "",
|
||||
code: 0,
|
||||
signal: null,
|
||||
killed: false,
|
||||
termination: "exit" as const,
|
||||
};
|
||||
runCommandWithTimeoutMock.mockResolvedValue(commandResult);
|
||||
|
||||
const runtime = createPluginRuntime();
|
||||
await expect(
|
||||
runtime.system.runCommandWithTimeout(["echo", "hello"], { timeoutMs: 1000 }),
|
||||
).resolves.toEqual(commandResult);
|
||||
expect(runCommandWithTimeoutMock).toHaveBeenCalledWith(["echo", "hello"], { timeoutMs: 1000 });
|
||||
});
|
||||
|
||||
it("fails closed when config loading throws", async () => {
|
||||
loadConfigMock.mockImplementation(() => {
|
||||
throw new Error("config read failed");
|
||||
});
|
||||
|
||||
const runtime = createPluginRuntime();
|
||||
await expect(
|
||||
runtime.system.runCommandWithTimeout(["echo", "hello"], { timeoutMs: 1000 }),
|
||||
).rejects.toThrow(blockedError);
|
||||
expect(runCommandWithTimeoutMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -105,6 +105,7 @@ import {
|
||||
readChannelAllowFromStore,
|
||||
upsertChannelPairingRequest,
|
||||
} from "../../pairing/pairing-store.js";
|
||||
import { runCommandWithTimeout } from "../../process/exec.js";
|
||||
import { resolveAgentRoute } from "../../routing/resolve-route.js";
|
||||
import { monitorSignalProvider } from "../../signal/index.js";
|
||||
import { probeSignal } from "../../signal/probe.js";
|
||||
@@ -235,12 +236,26 @@ function loadWhatsAppActions() {
|
||||
return whatsappActionsPromise;
|
||||
}
|
||||
|
||||
const runtimeCommandExecutionDisabled: PluginRuntime["system"]["runCommandWithTimeout"] =
|
||||
async () => {
|
||||
throw new Error(
|
||||
"runtime.system.runCommandWithTimeout is disabled for security hardening. Use fixed-purpose runtime APIs instead.",
|
||||
);
|
||||
};
|
||||
const RUNTIME_LEGACY_EXEC_DISABLED_ERROR =
|
||||
"runtime.system.runCommandWithTimeout is disabled for security hardening. Use fixed-purpose runtime APIs instead.";
|
||||
|
||||
function isLegacyPluginRuntimeExecEnabled(): boolean {
|
||||
try {
|
||||
return loadConfig().plugins?.runtime?.allowLegacyExec === true;
|
||||
} catch {
|
||||
// Fail closed if config is unreadable/invalid.
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
const runtimeCommandExecutionGuarded: PluginRuntime["system"]["runCommandWithTimeout"] = async (
|
||||
...args
|
||||
) => {
|
||||
if (!isLegacyPluginRuntimeExecEnabled()) {
|
||||
throw new Error(RUNTIME_LEGACY_EXEC_DISABLED_ERROR);
|
||||
}
|
||||
return await runCommandWithTimeout(...args);
|
||||
};
|
||||
|
||||
export function createPluginRuntime(): PluginRuntime {
|
||||
return {
|
||||
@@ -251,7 +266,7 @@ export function createPluginRuntime(): PluginRuntime {
|
||||
},
|
||||
system: {
|
||||
enqueueSystemEvent,
|
||||
runCommandWithTimeout: runtimeCommandExecutionDisabled,
|
||||
runCommandWithTimeout: runtimeCommandExecutionGuarded,
|
||||
formatNativeDependencyHint,
|
||||
},
|
||||
media: {
|
||||
|
||||
@@ -184,7 +184,10 @@ export type PluginRuntime = {
|
||||
};
|
||||
system: {
|
||||
enqueueSystemEvent: EnqueueSystemEvent;
|
||||
/** @deprecated Runtime command execution is disabled at runtime for security hardening. */
|
||||
/**
|
||||
* @deprecated Disabled by default for security hardening.
|
||||
* Set `plugins.runtime.allowLegacyExec: true` to opt in for legacy compatibility.
|
||||
*/
|
||||
runCommandWithTimeout: RunCommandWithTimeout;
|
||||
formatNativeDependencyHint: FormatNativeDependencyHint;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user