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.
| Package | Affected versions | Patched versions |
|---|---|---|
openclawnpm | < 2026.3.28 | 2026.3.28 |
Affected products
1Patches
1aa66ae1fc797Extensions: require admin for config write commands (#56002)
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- github.com/openclaw/openclaw/commit/aa66ae1fc797d3298cc409ed2c5da69a89950a45nvdPatch
- github.com/openclaw/openclaw/security/advisories/GHSA-h2v7-xc88-xx8cnvdPatchVendor AdvisoryWEB
- github.com/advisories/GHSA-h2v7-xc88-xx8cghsaADVISORY
- www.vulncheck.com/advisories/openclaw-authorization-bypass-in-phone-arm-and-phone-disarm-endpointsnvdThird Party Advisory
News mentions
0No linked articles in our index yet.