VYPR
Medium severity6.5NVD Advisory· Published Apr 28, 2026· Updated May 1, 2026

CVE-2026-41375

CVE-2026-41375

Description

OpenClaw before 2026.3.28 contains an authorization bypass vulnerability in the /phone arm and /phone disarm endpoints that fails to properly enforce operator.admin scope checks for external channels. Attackers can bypass authentication restrictions to arm or disarm phone channels without proper administrative privileges.

Affected packages

Versions sourced from the GitHub Security Advisory.

PackageAffected versionsPatched versions
openclawnpm
< 2026.3.282026.3.28

Affected products

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

Patches

1
aa66ae1fc797

Extensions: require admin for config write commands (#56002)

https://github.com/openclaw/openclawJacob TomlinsonMar 27, 2026via nvd-ref
4 files changed · +70 14
  • extensions/phone-control/index.test.ts+48 1 modified
    @@ -110,7 +110,11 @@ describe("phone-control plugin", () => {
         await withRegisteredPhoneControl(async ({ command, writeConfigFile, getConfig }) => {
           expect(command.name).toBe("phone");
     
    -      const res = await command.handler(createCommandContext("arm writes 30s"));
    +      const res = await command.handler({
    +        ...createCommandContext("arm writes 30s"),
    +        channel: "webchat",
    +        gatewayClientScopes: ["operator.admin"],
    +      });
           const text = String(res?.text ?? "");
           const nodes = (
             getConfig().gateway as { nodes?: { allowCommands?: string[]; denyCommands?: string[] } }
    @@ -139,6 +143,30 @@ describe("phone-control plugin", () => {
         });
       });
     
    +  it("blocks external channel callers without operator.admin from mutating phone control", async () => {
    +    await withRegisteredPhoneControl(async ({ command, writeConfigFile }) => {
    +      const res = await command.handler({
    +        ...createCommandContext("arm writes 30s"),
    +        channel: "telegram",
    +      });
    +
    +      expect(String(res?.text ?? "")).toContain("requires operator.admin");
    +      expect(writeConfigFile).not.toHaveBeenCalled();
    +    });
    +  });
    +
    +  it("blocks external channel callers without operator.admin from disarming phone control", async () => {
    +    await withRegisteredPhoneControl(async ({ command, writeConfigFile }) => {
    +      const res = await command.handler({
    +        ...createCommandContext("disarm"),
    +        channel: "telegram",
    +      });
    +
    +      expect(String(res?.text ?? "")).toContain("requires operator.admin");
    +      expect(writeConfigFile).not.toHaveBeenCalled();
    +    });
    +  });
    +
       it("allows internal operator.admin callers to mutate phone control", async () => {
         await withRegisteredPhoneControl(async ({ command, writeConfigFile }) => {
           const res = await command.handler({
    @@ -151,4 +179,23 @@ describe("phone-control plugin", () => {
           expect(writeConfigFile).toHaveBeenCalledTimes(1);
         });
       });
    +
    +  it("allows external channel callers with operator.admin to disarm phone control", async () => {
    +    await withRegisteredPhoneControl(async ({ command, writeConfigFile }) => {
    +      await command.handler({
    +        ...createCommandContext("arm writes 30s"),
    +        channel: "webchat",
    +        gatewayClientScopes: ["operator.admin"],
    +      });
    +
    +      const res = await command.handler({
    +        ...createCommandContext("disarm"),
    +        channel: "telegram",
    +        gatewayClientScopes: ["operator.admin"],
    +      });
    +
    +      expect(String(res?.text ?? "")).toContain("disarmed");
    +      expect(writeConfigFile).toHaveBeenCalledTimes(2);
    +    });
    +  });
     });
    
  • extensions/phone-control/index.ts+4 4 modified
    @@ -358,9 +358,9 @@ export default definePluginEntry({
             }
     
             if (action === "disarm") {
    -          if (ctx.channel === "webchat" && !ctx.gatewayClientScopes?.includes("operator.admin")) {
    +          if (!ctx.gatewayClientScopes?.includes("operator.admin")) {
                 return {
    -              text: "⚠️ /phone disarm requires operator.admin for internal gateway callers.",
    +              text: "⚠️ /phone disarm requires operator.admin.",
                 };
               }
               const res = await disarmNow({
    @@ -380,9 +380,9 @@ export default definePluginEntry({
             }
     
             if (action === "arm") {
    -          if (ctx.channel === "webchat" && !ctx.gatewayClientScopes?.includes("operator.admin")) {
    +          if (!ctx.gatewayClientScopes?.includes("operator.admin")) {
                 return {
    -              text: "⚠️ /phone arm requires operator.admin for internal gateway callers.",
    +              text: "⚠️ /phone arm requires operator.admin.",
                 };
               }
               const group = parseGroup(tokens[1]);
    
  • extensions/talk-voice/index.test.ts+13 3 modified
    @@ -179,7 +179,9 @@ describe("talk-voice plugin", () => {
         });
         vi.mocked(runtime.tts.listVoices).mockResolvedValue([{ id: "voice-a", name: "Claudia" }]);
     
    -    const result = await command.handler(createCommandContext("set Claudia"));
    +    const result = await command.handler(
    +      createCommandContext("set Claudia", "webchat", ["operator.admin"]),
    +    );
     
         expect(runtime.config.writeConfigFile).toHaveBeenCalledWith({
           talk: {
    @@ -209,7 +211,7 @@ describe("talk-voice plugin", () => {
         });
         vi.mocked(runtime.tts.listVoices).mockResolvedValue([{ id: "en-US-AvaNeural", name: "Ava" }]);
     
    -    await command.handler(createCommandContext("set Ava"));
    +    await command.handler(createCommandContext("set Ava", "webchat", ["operator.admin"]));
     
         expect(runtime.config.writeConfigFile).toHaveBeenCalledWith({
           talk: {
    @@ -247,10 +249,18 @@ describe("talk-voice plugin", () => {
         expect(runtime.config.writeConfigFile).not.toHaveBeenCalled();
       });
     
    -  it("allows /voice set from non-gateway channels without scope check", async () => {
    +  it("rejects /voice set from non-gateway channels without operator.admin", async () => {
         const { runtime, run } = createElevenlabsVoiceSetHarness("telegram");
         const result = await run();
     
    +    expect(result.text).toContain("requires operator.admin");
    +    expect(runtime.config.writeConfigFile).not.toHaveBeenCalled();
    +  });
    +
    +  it("allows /voice set when operator.admin is present on a non-webchat channel", async () => {
    +    const { runtime, run } = createElevenlabsVoiceSetHarness("telegram", ["operator.admin"]);
    +    const result = await run();
    +
         expect(runtime.config.writeConfigFile).toHaveBeenCalled();
         expect(result.text).toContain("voice-a");
       });
    
  • extensions/talk-voice/index.ts+5 6 modified
    @@ -164,12 +164,11 @@ export default definePluginEntry({
             }
     
             if (action === "set") {
    -          // Persistent config writes require operator.admin for gateway clients.
    -          // Without this check, a caller with only operator.write could bypass the
    -          // admin-only config.patch RPC by reaching writeConfigFile indirectly
    -          // through chat.send → /voice set.
    -          if (ctx.channel === "webchat" && !ctx.gatewayClientScopes?.includes("operator.admin")) {
    -            return { text: `⚠️ ${commandLabel} set requires operator.admin for gateway clients.` };
    +          // Persistent config writes require operator.admin on every channel.
    +          // Without this check, external channel senders could bypass the
    +          // admin-only config.patch RPC by reaching writeConfigFile indirectly.
    +          if (!ctx.gatewayClientScopes?.includes("operator.admin")) {
    +            return { text: `⚠️ ${commandLabel} set requires operator.admin.` };
               }
     
               const query = tokens.slice(1).join(" ").trim();
    

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.