VYPR
High severity8.8NVD Advisory· Published Apr 23, 2026· Updated Apr 28, 2026

CVE-2026-41352

CVE-2026-41352

Description

OpenClaw before 2026.3.31 contains a remote code execution vulnerability where a device-paired node can bypass the node scope gate authentication mechanism. Attackers with device pairing credentials can execute arbitrary node commands on the host system without proper node pairing validation.

Affected packages

Versions sourced from the GitHub Security Advisory.

PackageAffected versionsPatched versions
openclawnpm
< 2026.3.312026.3.31

Affected products

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

Patches

1
3886b65ef21d

fix(gateway): require node pairing before enabling node commands (#57777)

https://github.com/openclaw/openclawJacob TomlinsonMar 30, 2026via ghsa
2 files changed · +215 21
  • src/gateway/server.roles-allowlist-update.test.ts+184 10 modified
    @@ -98,6 +98,42 @@ const connectNodeClientWithPairing = async (params: Parameters<typeof connectNod
       }
     };
     
    +const connectNodeClientWithNodePairing = async (
    +  params: Parameters<typeof connectNodeClient>[0],
    +) => {
    +  const provisionalClient = await connectNodeClientWithPairing(params);
    +  const listRes = await rpcReq<{
    +    nodes?: Array<{ nodeId: string; displayName?: string; connected?: boolean }>;
    +  }>(ws, "node.list", {});
    +  const provisionalNode = (listRes.payload?.nodes ?? []).find((node) => {
    +    if (!node.connected) {
    +      return false;
    +    }
    +    if (params.displayName) {
    +      return node.displayName === params.displayName;
    +    }
    +    return true;
    +  });
    +  const nodeId = provisionalNode?.nodeId ?? "";
    +  expect(nodeId).toBeTruthy();
    +
    +  await provisionalClient.stopAndWait();
    +
    +  const { approveNodePairing, requestNodePairing } = await import("../infra/node-pairing.js");
    +  const request = await requestNodePairing({
    +    nodeId,
    +    displayName: params.displayName,
    +    platform: params.platform ?? "ios",
    +    deviceFamily: params.deviceFamily,
    +    commands: params.commands,
    +  });
    +  await approveNodePairing(request.request.requestId, {
    +    callerScopes: ["operator.admin", "operator.write"],
    +  });
    +
    +  return await connectNodeClient(params);
    +};
    +
     describe("gateway role enforcement", () => {
       test("enforces operator and node permissions", async () => {
         let nodeClient: GatewayClient | undefined;
    @@ -206,6 +242,7 @@ describe("gateway update.run", () => {
     
     describe("gateway node command allowlist", () => {
       test("enforces command allowlists across node clients", async () => {
    +    const { loadOrCreateDeviceIdentity } = await import("../infra/device-identity.js");
         const waitForConnectedCount = async (count: number) => {
           await expect
             .poll(async () => {
    @@ -234,11 +271,22 @@ describe("gateway node command allowlist", () => {
         let allowedClient: GatewayClient | undefined;
     
         try {
    +      const systemDeviceIdentity = loadOrCreateDeviceIdentity(
    +        path.join(os.tmpdir(), `openclaw-node-system-run-${Date.now()}-${Math.random()}.json`),
    +      );
    +      const emptyDeviceIdentity = loadOrCreateDeviceIdentity(
    +        path.join(os.tmpdir(), `openclaw-node-empty-${Date.now()}-${Math.random()}.json`),
    +      );
    +      const allowedDeviceIdentity = loadOrCreateDeviceIdentity(
    +        path.join(os.tmpdir(), `openclaw-node-allowed-${Date.now()}-${Math.random()}.json`),
    +      );
    +
           systemClient = await connectNodeClientWithPairing({
             port,
             commands: ["system.run"],
             instanceId: "node-system-run",
             displayName: "node-system-run",
    +        deviceIdentity: systemDeviceIdentity,
           });
           const systemNodeId = await getConnectedNodeId();
           const disallowedRes = await rpcReq(ws, "node.invoke", {
    @@ -249,14 +297,15 @@ describe("gateway node command allowlist", () => {
           });
           expect(disallowedRes.ok).toBe(false);
           expect(disallowedRes.error?.message).toContain("node command not allowed");
    -      systemClient.stop();
    +      await systemClient.stopAndWait();
           await waitForConnectedCount(0);
     
           emptyClient = await connectNodeClientWithPairing({
             port,
             commands: [],
             instanceId: "node-empty",
             displayName: "node-empty",
    +        deviceIdentity: emptyDeviceIdentity,
           });
           const emptyNodeId = await getConnectedNodeId();
           const missingRes = await rpcReq(ws, "node.invoke", {
    @@ -267,19 +316,20 @@ describe("gateway node command allowlist", () => {
           });
           expect(missingRes.ok).toBe(false);
           expect(missingRes.error?.message).toContain("node command not allowed");
    -      emptyClient.stop();
    +      await emptyClient.stopAndWait();
           await waitForConnectedCount(0);
     
           let resolveInvoke: ((payload: { id?: string; nodeId?: string }) => void) | null = null;
           const waitForInvoke = () =>
             new Promise<{ id?: string; nodeId?: string }>((resolve) => {
               resolveInvoke = resolve;
             });
    -      allowedClient = await connectNodeClientWithPairing({
    +      allowedClient = await connectNodeClientWithNodePairing({
             port,
             commands: ["canvas.snapshot"],
             instanceId: "node-allowed",
             displayName: "node-allowed",
    +        deviceIdentity: allowedDeviceIdentity,
             onEvent: (evt) => {
               if (evt.event === "node.invoke.request") {
                 const payload = evt.payload as { id?: string; nodeId?: string };
    @@ -325,9 +375,133 @@ describe("gateway node command allowlist", () => {
           const invokeNullRes = await invokeNullResP;
           expect(invokeNullRes.ok).toBe(true);
         } finally {
    -      systemClient?.stop();
    -      emptyClient?.stop();
    -      allowedClient?.stop();
    +      await systemClient?.stopAndWait();
    +      await emptyClient?.stopAndWait();
    +      await allowedClient?.stopAndWait();
    +    }
    +  });
    +
    +  test("blocks all declared commands until node pairing exists", async () => {
    +    const findConnectedNode = async (displayName: string) => {
    +      const listRes = await rpcReq<{
    +        nodes?: Array<{
    +          nodeId: string;
    +          displayName?: string;
    +          connected?: boolean;
    +          commands?: string[];
    +        }>;
    +      }>(ws, "node.list", {});
    +      return (listRes.payload?.nodes ?? []).find(
    +        (node) => node.connected && node.displayName === displayName,
    +      );
    +    };
    +
    +    const displayName = "node-device-paired-only";
    +    let nodeClient: GatewayClient | undefined;
    +
    +    try {
    +      nodeClient = await connectNodeClientWithPairing({
    +        port,
    +        commands: ["canvas.snapshot", "system.run"],
    +        platform: "darwin",
    +        instanceId: displayName,
    +        displayName,
    +      });
    +
    +      await expect
    +        .poll(async () => {
    +          const node = await findConnectedNode(displayName);
    +          return node?.commands?.toSorted() ?? [];
    +        }, FAST_WAIT_OPTS)
    +        .toEqual([]);
    +
    +      const node = await findConnectedNode(displayName);
    +      const nodeId = node?.nodeId ?? "";
    +      expect(nodeId).toBeTruthy();
    +
    +      const pairingList = await rpcReq<{
    +        pending?: Array<{ nodeId?: string; commands?: string[] }>;
    +      }>(ws, "node.pair.list", {});
    +      expect(pairingList.ok).toBe(true);
    +      expect(pairingList.payload?.pending ?? []).toEqual(
    +        expect.arrayContaining([
    +          expect.objectContaining({
    +            nodeId,
    +            commands: ["canvas.snapshot", "system.run"],
    +          }),
    +        ]),
    +      );
    +
    +      const canvasRes = await rpcReq(ws, "node.invoke", {
    +        nodeId,
    +        command: "canvas.snapshot",
    +        params: { format: "png" },
    +        idempotencyKey: "allowlist-device-paired-only-canvas",
    +      });
    +      expect(canvasRes.ok).toBe(false);
    +      expect(canvasRes.error?.message ?? "").toContain("node command not allowed");
    +
    +      const systemRunRes = await rpcReq(ws, "node.invoke", {
    +        nodeId,
    +        command: "system.run",
    +        params: { command: "echo blocked" },
    +        idempotencyKey: "allowlist-device-paired-only-system-run",
    +      });
    +      expect(systemRunRes.ok).toBe(false);
    +      expect(systemRunRes.error?.message ?? "").toContain("node command not allowed");
    +    } finally {
    +      await nodeClient?.stopAndWait();
    +    }
    +  });
    +
    +  test("records only allowlisted commands in pending node pairing requests", async () => {
    +    const { loadOrCreateDeviceIdentity } = await import("../infra/device-identity.js");
    +    const deviceIdentityPath = path.join(
    +      os.tmpdir(),
    +      `openclaw-allowlisted-pending-${Date.now()}-${Math.random().toString(36).slice(2)}.json`,
    +    );
    +    const deviceIdentity = loadOrCreateDeviceIdentity(deviceIdentityPath);
    +    const displayName = "node-pending-allowlisted-only";
    +    let nodeClient: GatewayClient | undefined;
    +
    +    try {
    +      nodeClient = await connectNodeClientWithPairing({
    +        port,
    +        commands: ["system.run", "canvas.snapshot"],
    +        platform: "İOS",
    +        deviceFamily: "iPhone",
    +        instanceId: displayName,
    +        displayName,
    +        deviceIdentity,
    +      });
    +
    +      const listRes = await rpcReq<{
    +        nodes?: Array<{
    +          nodeId: string;
    +          displayName?: string;
    +          connected?: boolean;
    +        }>;
    +      }>(ws, "node.list", {});
    +      const nodeId =
    +        (listRes.payload?.nodes ?? []).find(
    +          (node) => node.connected && node.displayName === displayName,
    +        )?.nodeId ?? "";
    +      expect(nodeId).toBeTruthy();
    +
    +      const pairingList = await rpcReq<{
    +        pending?: Array<{ nodeId?: string; commands?: string[] }>;
    +      }>(ws, "node.pair.list", {});
    +      expect(pairingList.ok).toBe(true);
    +      expect(pairingList.payload?.pending ?? []).toEqual(
    +        expect.arrayContaining([
    +          expect.objectContaining({
    +            nodeId,
    +            commands: ["canvas.snapshot"],
    +          }),
    +        ]),
    +      );
    +    } finally {
    +      await nodeClient?.stopAndWait();
         }
       });
     
    @@ -350,7 +524,7 @@ describe("gateway node command allowlist", () => {
             displayName: "node-platform-pin",
             deviceIdentity,
           });
    -      iosClient.stop();
    +      await iosClient.stopAndWait();
           await expect
             .poll(async () => {
               const listRes = await rpcReq<{ nodes?: Array<{ connected?: boolean }> }>(
    @@ -374,7 +548,7 @@ describe("gateway node command allowlist", () => {
             }),
           ).rejects.toThrow(/pairing required/i);
         } finally {
    -      iosClient?.stop();
    +      await iosClient?.stopAndWait();
         }
       });
     
    @@ -417,7 +591,7 @@ describe("gateway node command allowlist", () => {
     
           let client: GatewayClient | undefined;
           try {
    -        client = await connectNodeClientWithPairing({
    +        client = await connectNodeClientWithNodePairing({
               port,
               commands: ["system.run", "canvas.snapshot"],
               platform: testCase.platform,
    @@ -450,7 +624,7 @@ describe("gateway node command allowlist", () => {
             expect(systemRunRes.ok).toBe(false);
             expect(systemRunRes.error?.message ?? "").toContain("node command not allowed");
           } finally {
    -        client?.stop();
    +        await client?.stopAndWait();
           }
         }
       });
    
  • src/gateway/server/ws-connection/message-handler.ts+31 11 modified
    @@ -18,7 +18,11 @@ import {
       updatePairedDeviceMetadata,
       verifyDeviceToken,
     } from "../../../infra/device-pairing.js";
    -import { getPairedNode, updatePairedNodeMetadata } from "../../../infra/node-pairing.js";
    +import {
    +  getPairedNode,
    +  requestNodePairing,
    +  updatePairedNodeMetadata,
    +} from "../../../infra/node-pairing.js";
     import { recordRemoteNodeInfo, refreshRemoteNodeBins } from "../../../infra/skills-remote.js";
     import { upsertPresence } from "../../../infra/system-presence.js";
     import { loadVoiceWakeConfig } from "../../../infra/voicewake.js";
    @@ -964,21 +968,37 @@ export function attachGatewayWsMessageHandler(params: {
             if (role === "node") {
               const cfg = loadConfig();
               const nodeId = connectParams.device?.id ?? connectParams.client.id;
    -          const pairedNode = await getPairedNode(nodeId);
    +          const declared = Array.isArray(connectParams.commands) ? connectParams.commands : [];
               const allowlist = resolveNodeCommandAllowlist(cfg, {
                 platform: connectParams.client.platform,
                 deviceFamily: connectParams.client.deviceFamily,
               });
    -          const declared = Array.isArray(connectParams.commands) ? connectParams.commands : [];
    -          const pairedCommands = pairedNode ? new Set(pairedNode.commands ?? []) : null;
    -          const filtered = declared
    +          const allowlistedDeclared = declared
                 .map((cmd) => cmd.trim())
    -            .filter(
    -              (cmd) =>
    -                cmd.length > 0 &&
    -                allowlist.has(cmd) &&
    -                (pairedCommands === null || pairedCommands.has(cmd)),
    -            );
    +            .filter((cmd) => cmd.length > 0 && allowlist.has(cmd));
    +          let pairedNode = await getPairedNode(nodeId);
    +          if (!pairedNode) {
    +            const pending = await requestNodePairing({
    +              nodeId,
    +              displayName: connectParams.client.displayName,
    +              platform: connectParams.client.platform,
    +              version: connectParams.client.version,
    +              deviceFamily: connectParams.client.deviceFamily,
    +              modelIdentifier: connectParams.client.modelIdentifier,
    +              caps: connectParams.caps,
    +              commands: allowlistedDeclared,
    +              remoteIp: reportedClientIp,
    +            });
    +            if (pending.status === "pending" && pending.created) {
    +              const requestContext = buildRequestContext();
    +              requestContext.broadcast("node.pair.requested", pending.request, {
    +                dropIfSlow: true,
    +              });
    +            }
    +            pairedNode = await getPairedNode(nodeId);
    +          }
    +          const pairedCommands = new Set(pairedNode?.commands ?? []);
    +          const filtered = allowlistedDeclared.filter((cmd) => pairedCommands.has(cmd));
               connectParams.commands = filtered;
             }
     
    

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

6

News mentions

0

No linked articles in our index yet.