VYPR
High severity7.8NVD Advisory· Published Apr 21, 2026· Updated Apr 27, 2026

CVE-2026-41295

CVE-2026-41295

Description

OpenClaw before 2026.4.2 contains an improper trust boundary vulnerability allowing untrusted workspace channel shadows to execute during built-in channel setup and login. Attackers can clone a workspace with a malicious plugin claiming a bundled channel id to achieve unintended in-process code execution before the plugin is explicitly trusted.

Affected packages

Versions sourced from the GitHub Security Advisory.

PackageAffected versionsPatched versions
openclawnpm
< 2026.4.22026.4.2

Affected products

1
  • cpe:2.3:a:openclaw:openclaw:*:*:*:*:*:node.js:*:*
    Range: <2026.4.2

Patches

1
53c29df2a9eb

Channel setup: ignore untrusted workspace shadows (#59158)

https://github.com/openclaw/openclawmappel-nvApr 2, 2026via ghsa
4 files changed · +220 1
  • CHANGELOG.md+1 0 modified
    @@ -67,6 +67,7 @@ Docs: https://docs.openclaw.ai
     - Exec approvals/config: strip invalid `security`, `ask`, and `askFallback` values from `~/.openclaw/exec-approvals.json` during normalization so malformed policy enums fall back cleanly to the documented defaults instead of corrupting runtime policy resolution. (#59112) Thanks @openperf.
     - Gateway/session kill: enforce HTTP operator scopes on session kill requests and gate authorization before session lookup so unauthenticated callers cannot probe session existence. (#59128) Thanks @jacobtomlinson.
     - MS Teams/logging: format non-`Error` failures with the shared unknown-error helper so logs stop collapsing caught SDK or Axios objects into `[object Object]`. (#59321) Thanks @bradgroux.
    +- Channels/setup: ignore untrusted workspace channel plugins during setup resolution so a shadowing workspace plugin cannot override built-in channel setup/login flows unless explicitly trusted in config. (#59158) Thanks @mappel-nv.
     - Gateway: prune empty `node-pending-work` state entries after explicit acknowledgments and natural expiry so the per-node state map no longer grows indefinitely. (#58179) Thanks @gavyngong.
     - Webhooks/secret comparison: replace ad-hoc timing-safe secret comparisons across BlueBubbles, Feishu, Mattermost, Telegram, Twilio, and Zalo webhook handlers with the shared `safeEqualSecret` helper and reject empty auth tokens in BlueBubbles. (#58432) Thanks @eleqtrizit.
     - OpenShell/mirror: constrain `remoteWorkspaceDir` and `remoteAgentWorkspaceDir` to the managed `/sandbox` and `/agent` roots so mirror sync cannot escape the intended remote workspace paths. (#58515) Thanks @eleqtrizit.
    
  • src/channels/plugins/catalog.ts+6 0 modified
    @@ -30,6 +30,7 @@ export type ChannelUiCatalog = {
     export type ChannelPluginCatalogEntry = {
       id: string;
       pluginId?: string;
    +  origin?: PluginOrigin;
       meta: ChannelMeta;
       install: {
         npmSpec: string;
    @@ -43,6 +44,7 @@ type CatalogOptions = {
       catalogPaths?: string[];
       officialCatalogPaths?: string[];
       env?: NodeJS.ProcessEnv;
    +  excludeWorkspace?: boolean;
     };
     
     const ORIGIN_PRIORITY: Record<PluginOrigin, number> = {
    @@ -294,6 +296,7 @@ function buildCatalogEntry(candidate: {
       return {
         id,
         ...(pluginId ? { pluginId } : {}),
    +    ...(candidate.origin ? { origin: candidate.origin } : {}),
         meta,
         install,
       };
    @@ -385,6 +388,9 @@ export function listChannelPluginCatalogEntries(
       const resolved = new Map<string, { entry: ChannelPluginCatalogEntry; priority: number }>();
     
       for (const candidate of discovery.candidates) {
    +    if (options.excludeWorkspace && candidate.origin === "workspace") {
    +      continue;
    +    }
         const entry = buildCatalogEntry(candidate);
         if (!entry) {
           continue;
    
  • src/commands/channel-setup/channel-plugin-resolution.test.ts+159 0 added
    @@ -0,0 +1,159 @@
    +import { beforeEach, describe, expect, it, vi } from "vitest";
    +import type { ChannelPluginCatalogEntry } from "../../channels/plugins/catalog.js";
    +import type { ChannelPlugin } from "../../channels/plugins/types.js";
    +
    +const mocks = vi.hoisted(() => ({
    +  resolveAgentWorkspaceDir: vi.fn(() => "/tmp/workspace"),
    +  resolveDefaultAgentId: vi.fn(() => "default"),
    +  listChannelPluginCatalogEntries: vi.fn(),
    +  getChannelPluginCatalogEntry: vi.fn(),
    +  getChannelPlugin: vi.fn(),
    +  loadChannelSetupPluginRegistrySnapshotForChannel: vi.fn(),
    +  ensureChannelSetupPluginInstalled: vi.fn(),
    +  createClackPrompter: vi.fn(() => ({}) as never),
    +}));
    +
    +vi.mock("../../agents/agent-scope.js", () => ({
    +  resolveAgentWorkspaceDir: mocks.resolveAgentWorkspaceDir,
    +  resolveDefaultAgentId: mocks.resolveDefaultAgentId,
    +}));
    +
    +vi.mock("../../channels/plugins/catalog.js", () => ({
    +  listChannelPluginCatalogEntries: mocks.listChannelPluginCatalogEntries,
    +  getChannelPluginCatalogEntry: mocks.getChannelPluginCatalogEntry,
    +}));
    +
    +vi.mock("../../channels/plugins/index.js", () => ({
    +  getChannelPlugin: mocks.getChannelPlugin,
    +  normalizeChannelId: (value: unknown) => (typeof value === "string" ? value.trim() || null : null),
    +}));
    +
    +vi.mock("./plugin-install.js", () => ({
    +  loadChannelSetupPluginRegistrySnapshotForChannel:
    +    mocks.loadChannelSetupPluginRegistrySnapshotForChannel,
    +  ensureChannelSetupPluginInstalled: mocks.ensureChannelSetupPluginInstalled,
    +}));
    +
    +vi.mock("../../wizard/clack-prompter.js", () => ({
    +  createClackPrompter: mocks.createClackPrompter,
    +}));
    +
    +import { resolveInstallableChannelPlugin } from "./channel-plugin-resolution.js";
    +
    +function createCatalogEntry(params: {
    +  id: string;
    +  pluginId: string;
    +  origin?: "workspace" | "bundled";
    +}): ChannelPluginCatalogEntry {
    +  return {
    +    id: params.id,
    +    pluginId: params.pluginId,
    +    origin: params.origin,
    +    meta: {
    +      id: params.id,
    +      label: "Telegram",
    +      selectionLabel: "Telegram",
    +      docsPath: "/channels/telegram",
    +      blurb: "Telegram channel",
    +    },
    +    install: {
    +      npmSpec: params.pluginId,
    +    },
    +  };
    +}
    +
    +function createPlugin(id: string): ChannelPlugin {
    +  return { id } as ChannelPlugin;
    +}
    +
    +describe("resolveInstallableChannelPlugin", () => {
    +  beforeEach(() => {
    +    vi.clearAllMocks();
    +    mocks.getChannelPlugin.mockReturnValue(undefined);
    +    mocks.ensureChannelSetupPluginInstalled.mockResolvedValue({
    +      cfg: {},
    +      installed: false,
    +    });
    +  });
    +
    +  it("ignores untrusted workspace channel shadows during setup resolution", async () => {
    +    const workspaceEntry = createCatalogEntry({
    +      id: "telegram",
    +      pluginId: "evil-telegram-shadow",
    +      origin: "workspace",
    +    });
    +    const bundledEntry = createCatalogEntry({
    +      id: "telegram",
    +      pluginId: "telegram",
    +      origin: "bundled",
    +    });
    +    const bundledPlugin = createPlugin("telegram");
    +
    +    mocks.listChannelPluginCatalogEntries.mockImplementation(
    +      ({ excludeWorkspace }: { excludeWorkspace?: boolean }) =>
    +        excludeWorkspace ? [bundledEntry] : [workspaceEntry],
    +    );
    +    mocks.loadChannelSetupPluginRegistrySnapshotForChannel.mockImplementation(
    +      ({ pluginId }: { pluginId?: string }) => ({
    +        channels: pluginId === "telegram" ? [{ plugin: bundledPlugin }] : [],
    +        channelSetups: [],
    +      }),
    +    );
    +
    +    const result = await resolveInstallableChannelPlugin({
    +      cfg: { plugins: { enabled: true } },
    +      runtime: {} as never,
    +      rawChannel: "telegram",
    +      allowInstall: false,
    +    });
    +
    +    expect(result.catalogEntry?.pluginId).toBe("telegram");
    +    expect(result.plugin?.id).toBe("telegram");
    +    expect(mocks.loadChannelSetupPluginRegistrySnapshotForChannel).toHaveBeenCalledWith(
    +      expect.objectContaining({
    +        channel: "telegram",
    +        pluginId: "telegram",
    +        workspaceDir: "/tmp/workspace",
    +      }),
    +    );
    +  });
    +
    +  it("keeps trusted workspace channel plugins eligible for setup resolution", async () => {
    +    const workspaceEntry = createCatalogEntry({
    +      id: "telegram",
    +      pluginId: "evil-telegram-shadow",
    +      origin: "workspace",
    +    });
    +    const workspacePlugin = createPlugin("telegram");
    +
    +    mocks.listChannelPluginCatalogEntries.mockReturnValue([workspaceEntry]);
    +    mocks.loadChannelSetupPluginRegistrySnapshotForChannel.mockImplementation(
    +      ({ pluginId }: { pluginId?: string }) => ({
    +        channels: pluginId === "evil-telegram-shadow" ? [{ plugin: workspacePlugin }] : [],
    +        channelSetups: [],
    +      }),
    +    );
    +
    +    const result = await resolveInstallableChannelPlugin({
    +      cfg: {
    +        plugins: {
    +          enabled: true,
    +          allow: ["evil-telegram-shadow"],
    +        },
    +      },
    +      runtime: {} as never,
    +      rawChannel: "telegram",
    +      allowInstall: false,
    +    });
    +
    +    expect(result.catalogEntry?.pluginId).toBe("evil-telegram-shadow");
    +    expect(result.plugin?.id).toBe("telegram");
    +    expect(mocks.loadChannelSetupPluginRegistrySnapshotForChannel).toHaveBeenCalledWith(
    +      expect.objectContaining({
    +        channel: "telegram",
    +        pluginId: "evil-telegram-shadow",
    +        workspaceDir: "/tmp/workspace",
    +      }),
    +    );
    +  });
    +});
    
  • src/commands/channel-setup/channel-plugin-resolution.ts+54 1 modified
    @@ -7,6 +7,7 @@ import {
     import { getChannelPlugin, normalizeChannelId } from "../../channels/plugins/index.js";
     import type { ChannelId, ChannelPlugin } from "../../channels/plugins/types.js";
     import type { OpenClawConfig } from "../../config/config.js";
    +import { normalizePluginsConfig, resolveEnableState } from "../../plugins/config-state.js";
     import type { RuntimeEnv } from "../../runtime.js";
     import { createClackPrompter } from "../../wizard/clack-prompter.js";
     import type { WizardPrompter } from "../../wizard/prompts.js";
    @@ -70,6 +71,51 @@ function findScopedChannelPlugin(
       );
     }
     
    +function isTrustedWorkspaceChannelCatalogEntry(
    +  entry: ChannelPluginCatalogEntry | undefined,
    +  cfg: OpenClawConfig,
    +): boolean {
    +  if (entry?.origin !== "workspace") {
    +    return true;
    +  }
    +  if (!entry.pluginId) {
    +    return false;
    +  }
    +  return resolveEnableState(entry.pluginId, "workspace", normalizePluginsConfig(cfg.plugins))
    +    .enabled;
    +}
    +
    +function resolveTrustedCatalogEntry(params: {
    +  rawChannel?: string | null;
    +  channelId?: ChannelId;
    +  cfg: OpenClawConfig;
    +  workspaceDir?: string;
    +  catalogEntry?: ChannelPluginCatalogEntry;
    +}): ChannelPluginCatalogEntry | undefined {
    +  if (isTrustedWorkspaceChannelCatalogEntry(params.catalogEntry, params.cfg)) {
    +    return params.catalogEntry;
    +  }
    +  if (params.rawChannel) {
    +    const trimmed = params.rawChannel.trim().toLowerCase();
    +    return listChannelPluginCatalogEntries({
    +      workspaceDir: params.workspaceDir,
    +      excludeWorkspace: true,
    +    }).find((entry) => {
    +      if (entry.id.toLowerCase() === trimmed) {
    +        return true;
    +      }
    +      return (entry.meta.aliases ?? []).some((alias) => alias.trim().toLowerCase() === trimmed);
    +    });
    +  }
    +  if (!params.channelId) {
    +    return undefined;
    +  }
    +  return getChannelPluginCatalogEntry(params.channelId, {
    +    workspaceDir: params.workspaceDir,
    +    excludeWorkspace: true,
    +  });
    +}
    +
     function loadScopedChannelPlugin(params: {
       cfg: OpenClawConfig;
       runtime: RuntimeEnv;
    @@ -99,13 +145,20 @@ export async function resolveInstallableChannelPlugin(params: {
       const supports = params.supports ?? (() => true);
       let nextCfg = params.cfg;
       const workspaceDir = resolveWorkspaceDir(nextCfg);
    -  const catalogEntry =
    +  const unresolvedCatalogEntry =
         (params.rawChannel ? resolveCatalogChannelEntry(params.rawChannel, nextCfg) : undefined) ??
         (params.channelId
           ? getChannelPluginCatalogEntry(params.channelId, {
               workspaceDir,
             })
           : undefined);
    +  const catalogEntry = resolveTrustedCatalogEntry({
    +    rawChannel: params.rawChannel,
    +    channelId: params.channelId,
    +    cfg: nextCfg,
    +    workspaceDir,
    +    catalogEntry: unresolvedCatalogEntry,
    +  });
       const channelId =
         params.channelId ??
         resolveResolvedChannelId({
    

Vulnerability mechanics

Generated by null/stub on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.

References

4

News mentions

0

No linked articles in our index yet.