VYPR
Critical severityNVD Advisory· Published Mar 5, 2026· Updated Mar 9, 2026

OpenClaw < 2026.2.14 - Remote Code Execution via Node Invoke Approval Bypass

CVE-2026-28466

Description

OpenClaw versions prior to 2026.2.14 contain a vulnerability in the gateway in which it fails to sanitize internal approval fields in node.invoke parameters, allowing authenticated clients to bypass exec approval gating for system.run commands. Attackers with valid gateway credentials can inject approval control fields to execute arbitrary commands on connected node hosts, potentially compromising developer workstations and CI runners.

Affected packages

Versions sourced from the GitHub Security Advisory.

PackageAffected versionsPatched versions
openclawnpm
< 2026.2.142026.2.14

Affected products

1

Patches

4
0af76f5f0e93

refactor(gateway): centralize node.invoke param sanitization

https://github.com/openclaw/openclawPeter SteinbergerFeb 14, 2026via ghsa
3 files changed · +121 9
  • src/gateway/node-invoke-sanitize.ts+21 0 added
    @@ -0,0 +1,21 @@
    +import type { ExecApprovalManager } from "./exec-approval-manager.js";
    +import type { GatewayClient } from "./server-methods/types.js";
    +import { sanitizeSystemRunParamsForForwarding } from "./node-invoke-system-run-approval.js";
    +
    +export function sanitizeNodeInvokeParamsForForwarding(opts: {
    +  command: string;
    +  rawParams: unknown;
    +  client: GatewayClient | null;
    +  execApprovalManager?: ExecApprovalManager;
    +}):
    +  | { ok: true; params: unknown }
    +  | { ok: false; message: string; details?: Record<string, unknown> } {
    +  if (opts.command === "system.run") {
    +    return sanitizeSystemRunParamsForForwarding({
    +      rawParams: opts.rawParams,
    +      client: opts.client,
    +      execApprovalManager: opts.execApprovalManager,
    +    });
    +  }
    +  return { ok: true, params: opts.rawParams };
    +}
    
  • src/gateway/server-methods/nodes.ts+7 9 modified
    @@ -10,7 +10,7 @@ import {
       verifyNodeToken,
     } from "../../infra/node-pairing.js";
     import { isNodeCommandAllowed, resolveNodeCommandAllowlist } from "../node-command-policy.js";
    -import { sanitizeSystemRunParamsForForwarding } from "../node-invoke-system-run-approval.js";
    +import { sanitizeNodeInvokeParamsForForwarding } from "../node-invoke-sanitize.js";
     import {
       ErrorCodes,
       errorShape,
    @@ -418,14 +418,12 @@ export const nodeHandlers: GatewayRequestHandlers = {
             );
             return;
           }
    -      const forwardedParams =
    -        command === "system.run"
    -          ? sanitizeSystemRunParamsForForwarding({
    -              rawParams: p.params,
    -              client,
    -              execApprovalManager: context.execApprovalManager,
    -            })
    -          : ({ ok: true, params: p.params } as const);
    +      const forwardedParams = sanitizeNodeInvokeParamsForForwarding({
    +        command,
    +        rawParams: p.params,
    +        client,
    +        execApprovalManager: context.execApprovalManager,
    +      });
           if (!forwardedParams.ok) {
             respond(
               false,
    
  • src/gateway/server.node-invoke-approval-bypass.e2e.test.ts+93 0 modified
    @@ -1,9 +1,15 @@
     import crypto from "node:crypto";
     import { afterAll, beforeAll, describe, expect, test } from "vitest";
     import { WebSocket } from "ws";
    +import {
    +  deriveDeviceIdFromPublicKey,
    +  publicKeyRawBase64UrlFromPem,
    +  signDevicePayload,
    +} from "../infra/device-identity.js";
     import { sleep } from "../utils.js";
     import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../utils/message-channel.js";
     import { GatewayClient } from "./client.js";
    +import { buildDeviceAuthPayload } from "./device-auth.js";
     import {
       connectReq,
       installGatewayTestHooks,
    @@ -35,6 +41,39 @@ describe("node.invoke approval bypass", () => {
         return ws;
       };
     
    +  const connectOperatorWithNewDevice = async (scopes: string[]) => {
    +    const { publicKey, privateKey } = crypto.generateKeyPairSync("ed25519");
    +    const publicKeyPem = publicKey.export({ type: "spki", format: "pem" }).toString();
    +    const privateKeyPem = privateKey.export({ type: "pkcs8", format: "pem" }).toString();
    +    const publicKeyRaw = publicKeyRawBase64UrlFromPem(publicKeyPem);
    +    const deviceId = deriveDeviceIdFromPublicKey(publicKeyRaw);
    +    expect(deviceId).toBeTruthy();
    +    const signedAtMs = Date.now();
    +    const payload = buildDeviceAuthPayload({
    +      deviceId: deviceId!,
    +      clientId: GATEWAY_CLIENT_NAMES.TEST,
    +      clientMode: GATEWAY_CLIENT_MODES.TEST,
    +      role: "operator",
    +      scopes,
    +      signedAtMs,
    +      token: "secret",
    +    });
    +    const ws = new WebSocket(`ws://127.0.0.1:${port}`);
    +    await new Promise<void>((resolve) => ws.once("open", resolve));
    +    const res = await connectReq(ws, {
    +      token: "secret",
    +      scopes,
    +      device: {
    +        id: deviceId!,
    +        publicKey: publicKeyRaw,
    +        signature: signDevicePayload(privateKeyPem, payload),
    +        signedAt: signedAtMs,
    +      },
    +    });
    +    expect(res.ok).toBe(true);
    +    return ws;
    +  };
    +
       const connectLinuxNode = async (onInvoke: (payload: unknown) => void) => {
         let readyResolve: (() => void) | null = null;
         const ready = new Promise<void>((resolve) => {
    @@ -172,6 +211,7 @@ describe("node.invoke approval bypass", () => {
             approved: true,
             // Try to escalate to allow-always; gateway should clamp to allow-once from record.
             approvalDecision: "allow-always",
    +        injected: "nope",
           },
           idempotencyKey: crypto.randomUUID(),
         });
    @@ -180,9 +220,62 @@ describe("node.invoke approval bypass", () => {
         expect(lastInvokeParams).toBeTruthy();
         expect(lastInvokeParams?.approved).toBe(true);
         expect(lastInvokeParams?.approvalDecision).toBe("allow-once");
    +    expect(lastInvokeParams?.injected).toBeUndefined();
     
         ws.close();
         ws2.close();
         node.stop();
       });
    +
    +  test("rejects replaying approval id from another device", async () => {
    +    let sawInvoke = false;
    +    const node = await connectLinuxNode(() => {
    +      sawInvoke = true;
    +    });
    +
    +    const ws = await connectOperator(["operator.write", "operator.approvals"]);
    +    const wsOtherDevice = await connectOperatorWithNewDevice(["operator.write"]);
    +
    +    const nodes = await rpcReq<{ nodes?: Array<{ nodeId: string; connected?: boolean }> }>(
    +      ws,
    +      "node.list",
    +      {},
    +    );
    +    expect(nodes.ok).toBe(true);
    +    const nodeId = nodes.payload?.nodes?.find((n) => n.connected)?.nodeId ?? "";
    +    expect(nodeId).toBeTruthy();
    +
    +    const approvalId = crypto.randomUUID();
    +    const requestP = rpcReq(ws, "exec.approval.request", {
    +      id: approvalId,
    +      command: "echo hi",
    +      cwd: null,
    +      host: "node",
    +      timeoutMs: 30_000,
    +    });
    +    await rpcReq(ws, "exec.approval.resolve", { id: approvalId, decision: "allow-once" });
    +    const requested = await requestP;
    +    expect(requested.ok).toBe(true);
    +
    +    const invoke = await rpcReq(wsOtherDevice, "node.invoke", {
    +      nodeId,
    +      command: "system.run",
    +      params: {
    +        command: ["echo", "hi"],
    +        rawCommand: "echo hi",
    +        runId: approvalId,
    +        approved: true,
    +        approvalDecision: "allow-once",
    +      },
    +      idempotencyKey: crypto.randomUUID(),
    +    });
    +    expect(invoke.ok).toBe(false);
    +    expect(invoke.error?.message ?? "").toContain("not valid for this device");
    +    await sleep(50);
    +    expect(sawInvoke).toBe(false);
    +
    +    ws.close();
    +    wsOtherDevice.close();
    +    node.stop();
    +  });
     });
    

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

8

News mentions

0

No linked articles in our index yet.