Critical severity9.9NVD Advisory· Published Mar 31, 2026· Updated Apr 6, 2026
CVE-2026-33579
CVE-2026-33579
Description
OpenClaw before 2026.3.28 contains a privilege escalation vulnerability in the /pair approve command path that fails to forward caller scopes into the core approval check. A caller with pairing privileges but without admin privileges can approve pending device requests asking for broader scopes including admin access by exploiting the missing scope validation in extensions/device-pair/index.ts and src/infra/device-pairing.ts.
Affected packages
Versions sourced from the GitHub Security Advisory.
| Package | Affected versions | Patched versions |
|---|---|---|
openclawnpm | < 2026.3.28 | 2026.3.28 |
Affected products
1Patches
24ee4960de233Pairing: forward caller scopes during approval (#55950)
14 files changed · +148 −27
extensions/device-pair/index.test.ts+37 −1 modified@@ -510,7 +510,43 @@ describe("device-pair /pair approve", () => { }), ); - expect(vi.mocked(approveDevicePairing)).toHaveBeenCalledWith("req-1"); + expect(vi.mocked(approveDevicePairing)).toHaveBeenCalledWith("req-1", { + callerScopes: ["operator.write", "operator.pairing"], + }); expect(result).toEqual({ text: "✅ Paired Victim Phone (ios)." }); }); + + it("rejects approvals above the caller scopes", async () => { + vi.mocked(listDevicePairing).mockResolvedValueOnce({ + pending: [ + { + requestId: "req-1", + deviceId: "victim-phone", + publicKey: "victim-public-key", + displayName: "Victim Phone", + platform: "ios", + ts: Date.now(), + }, + ], + paired: [], + }); + vi.mocked(approveDevicePairing).mockResolvedValueOnce({ + status: "forbidden", + missingScope: "operator.admin", + }); + + const command = registerPairCommand(); + const result = await command.handler( + createCommandContext({ + channel: "webchat", + args: "approve latest", + commandBody: "/pair approve latest", + gatewayClientScopes: ["operator.write", "operator.pairing"], + }), + ); + + expect(result).toEqual({ + text: "⚠️ Cannot approve a request requiring operator.admin.", + }); + }); });
extensions/device-pair/index.ts+6 −1 modified@@ -611,10 +611,15 @@ export default definePluginEntry({ if (!pending) { return { text: "Pairing request not found." }; } - const approved = await approveDevicePairing(pending.requestId); + const approved = await approveDevicePairing(pending.requestId, { + callerScopes: gatewayClientScopes ?? [], + }); if (!approved) { return { text: "Pairing request not found." }; } + if (approved.status === "forbidden") { + return { text: `⚠️ Cannot approve a request requiring ${approved.missingScope}.` }; + } const label = approved.device.displayName?.trim() || approved.device.deviceId; const platform = approved.device.platform?.trim(); const platformLabel = platform ? ` (${platform})` : "";
src/cli/devices-cli.test.ts+3 −1 modified@@ -280,7 +280,9 @@ describe("devices cli local fallback", () => { await runDevicesApprove(["--latest"]); - expect(approveDevicePairing).toHaveBeenCalledWith("req-latest"); + expect(approveDevicePairing).toHaveBeenCalledWith("req-latest", { + callerScopes: ["operator.admin"], + }); expect(runtime.log).toHaveBeenCalledWith(expect.stringContaining(fallbackNotice)); expect(runtime.log).toHaveBeenCalledWith(expect.stringContaining("Approved")); });
src/cli/devices-cli.ts+8 −1 modified@@ -159,10 +159,17 @@ async function approvePairingWithFallback( if (opts.json !== true) { defaultRuntime.log(theme.warn(FALLBACK_NOTICE)); } - const approved = await approveDevicePairing(requestId); + const approved = await approveDevicePairing(requestId, { + // Local CLI fallback already assumes direct machine access; treat it as an + // explicit admin approval path instead of relying on missing caller scopes. + callerScopes: ["operator.admin"], + }); if (!approved) { return null; } + if (approved.status === "forbidden") { + throw new Error(`missing scope: ${approved.missingScope}`, { cause: error }); + } return { requestId, device: redactLocalPairedDevice(approved.device),
src/gateway/device-authz.test-helpers.ts+3 −1 modified@@ -54,7 +54,9 @@ export async function pairDeviceIdentity(params: { clientId: params.clientId, clientMode: params.clientMode, }); - await approveDevicePairing(request.request.requestId); + await approveDevicePairing(request.request.requestId, { + callerScopes: params.scopes, + }); return loaded; }
src/gateway/server.auth.compat-baseline.test.ts+3 −1 modified@@ -167,7 +167,9 @@ describe("gateway auth compatibility baseline", () => { role: "operator", scopes: ["operator.admin"], }); - await approveDevicePairing(pending.request.requestId); + await approveDevicePairing(pending.request.requestId, { + callerScopes: ["operator.admin"], + }); const rotated = await rotateDeviceToken({ deviceId: identity.deviceId,
src/gateway/server.auth.control-ui.suite.ts+9 −3 modified@@ -201,7 +201,9 @@ export function registerControlUiAndPairingSuite(): void { displayName: params.displayName, platform: params.platform, }); - await approveDevicePairing(seeded.request.requestId); + await approveDevicePairing(seeded.request.requestId, { + callerScopes: ["operator.admin"], + }); return { identityPath, identity: { deviceId: identity.deviceId } }; }; @@ -761,7 +763,9 @@ export function registerControlUiAndPairingSuite(): void { if (!pendingForTestDevice[0]) { throw new Error("expected pending pairing request"); } - await approveDevicePairing(pendingForTestDevice[0].requestId); + await approveDevicePairing(pendingForTestDevice[0].requestId, { + callerScopes: pendingForTestDevice[0].scopes ?? ["operator.admin"], + }); const paired = await getPairedDevice(identity.deviceId); expect(paired?.roles).toEqual(expect.arrayContaining(["node", "operator"])); @@ -843,7 +847,9 @@ export function registerControlUiAndPairingSuite(): void { displayName: "legacy-test", platform: "test", }); - await approveDevicePairing(pending.request.requestId); + await approveDevicePairing(pending.request.requestId, { + callerScopes: pending.request.scopes ?? ["operator.admin"], + }); await stripPairedMetadataRolesAndScopes(deviceId);
src/gateway/server.auth.shared.ts+3 −1 modified@@ -212,7 +212,9 @@ async function approvePendingPairingIfNeeded() { const pending = list.pending.at(0); expect(pending?.requestId).toBeDefined(); if (pending?.requestId) { - await approveDevicePairing(pending.requestId); + await approveDevicePairing(pending.requestId, { + callerScopes: pending.scopes ?? ["operator.admin"], + }); } }
src/gateway/server.node-invoke-approval-bypass.test.ts+3 −1 modified@@ -117,7 +117,9 @@ describe("node.invoke approval bypass", () => { const { approveDevicePairing, listDevicePairing } = await import("../infra/device-pairing.js"); const list = await listDevicePairing(); for (const pending of list.pending) { - await approveDevicePairing(pending.requestId); + await approveDevicePairing(pending.requestId, { + callerScopes: pending.scopes ?? ["operator.admin"], + }); } };
src/gateway/server.roles-allowlist-update.test.ts+3 −1 modified@@ -72,7 +72,9 @@ const approveAllPendingPairings = async () => { const { approveDevicePairing, listDevicePairing } = await import("../infra/device-pairing.js"); const list = await listDevicePairing(); for (const pending of list.pending) { - await approveDevicePairing(pending.requestId); + await approveDevicePairing(pending.requestId, { + callerScopes: pending.scopes ?? ["operator.admin"], + }); } };
src/gateway/server.sessions-send.test.ts+3 −1 modified@@ -94,7 +94,9 @@ beforeAll(async () => { scopes: ["operator.admin", "operator.read", "operator.write", "operator.approvals"], silent: false, }); - await approveDevicePairing(pending.request.requestId); + await approveDevicePairing(pending.request.requestId, { + callerScopes: pending.request.scopes ?? ["operator.admin"], + }); server = await startGatewayServer(gatewayPort); });
src/gateway/server/ws-connection/message-handler.ts+10 −3 modified@@ -806,8 +806,10 @@ export function attachGatewayWsMessageHandler(params: { return replacementPending?.requestId; }; if (pairing.request.silent === true) { - approved = await approveDevicePairing(pairing.request.requestId); - if (approved) { + approved = await approveDevicePairing(pairing.request.requestId, { + callerScopes: scopes, + }); + if (approved?.status === "approved") { logGateway.info( `device pairing auto-approved device=${approved.device.deviceId} role=${approved.device.role ?? "unknown"}`, ); @@ -839,7 +841,12 @@ export function attachGatewayWsMessageHandler(params: { } // Re-resolve: another connection may have superseded/approved the request since we created it recoveryRequestId = await resolveLivePendingRequestId(); - if (!(pairing.request.silent === true && (approved || resolvedByConcurrentApproval))) { + if ( + !( + pairing.request.silent === true && + (approved?.status === "approved" || resolvedByConcurrentApproval) + ) + ) { setHandshakeState("failed"); setCloseCause("pairing-required", { deviceId: device.id,
src/infra/device-pairing.test.ts+49 −4 modified@@ -28,7 +28,7 @@ async function setupPairedOperatorDevice(baseDir: string, scopes: string[]) { }, baseDir, ); - await approveDevicePairing(request.request.requestId, baseDir); + await approveDevicePairing(request.request.requestId, { callerScopes: scopes }, baseDir); } async function setupOperatorToken(scopes: string[]) { @@ -158,7 +158,11 @@ describe("device pairing tokens", () => { expect(list.pending).toHaveLength(1); expect(list.pending[0]?.requestId).toBe(second.request.requestId); - await approveDevicePairing(second.request.requestId, baseDir); + await approveDevicePairing( + second.request.requestId, + { callerScopes: ["operator.read", "operator.write"] }, + baseDir, + ); const paired = await getPairedDevice("device-1", baseDir); expect(paired?.roles).toEqual(expect.arrayContaining(["node", "operator"])); expect(paired?.scopes).toEqual(expect.arrayContaining(["operator.read", "operator.write"])); @@ -234,13 +238,50 @@ describe("device pairing tokens", () => { }), ).resolves.toEqual({ ok: false, reason: "bootstrap_token_invalid" }); - await approveDevicePairing(first.request.requestId, baseDir); + await approveDevicePairing( + first.request.requestId, + { callerScopes: ["operator.read"] }, + baseDir, + ); const paired = await getPairedDevice("device-1", baseDir); expect(paired?.scopes).toEqual(["operator.read"]); expect(paired?.approvedScopes).toEqual(["operator.read"]); expect(paired?.tokens?.operator?.scopes).toEqual(["operator.read"]); }); + test("fails closed for operator approvals when caller scopes are omitted", async () => { + const baseDir = await mkdtemp(join(tmpdir(), "openclaw-device-pairing-")); + const request = await requestDevicePairing( + { + deviceId: "device-1", + publicKey: "public-key-1", + role: "operator", + scopes: ["operator.admin"], + }, + baseDir, + ); + + await expect(approveDevicePairing(request.request.requestId, baseDir)).resolves.toEqual({ + status: "forbidden", + missingScope: "operator.admin", + }); + + await expect( + approveDevicePairing( + request.request.requestId, + { + callerScopes: ["operator.admin"], + }, + baseDir, + ), + ).resolves.toEqual( + expect.objectContaining({ + status: "approved", + requestId: request.request.requestId, + }), + ); + }); + test("generates base64url device tokens with 256-bit entropy output length", async () => { const baseDir = await mkdtemp(join(tmpdir(), "openclaw-device-pairing-")); await setupPairedOperatorDevice(baseDir, ["operator.admin"]); @@ -289,7 +330,11 @@ describe("device pairing tokens", () => { }, baseDir, ); - await approveDevicePairing(repair.request.requestId, baseDir); + await approveDevicePairing( + repair.request.requestId, + { callerScopes: ["operator.admin"] }, + baseDir, + ); const paired = await getPairedDevice("device-1", baseDir); expect(paired?.scopes).toEqual(["operator.admin"]);
src/infra/device-pairing.ts+8 −7 modified@@ -85,11 +85,6 @@ export type ApproveDevicePairingResult = | { status: "forbidden"; missingScope: string } | null; -type ApprovedDevicePairingResult = Extract< - NonNullable<ApproveDevicePairingResult>, - { status: "approved" } ->; - type DevicePairingStateFile = { pendingById: Record<string, DevicePairingPendingRequest>; pairedByDeviceId: Record<string, PairedDevice>; @@ -445,7 +440,7 @@ export async function requestDevicePairing( export async function approveDevicePairing( requestId: string, baseDir?: string, -): Promise<ApprovedDevicePairingResult | null>; +): Promise<ApproveDevicePairingResult>; export async function approveDevicePairing( requestId: string, options: { callerScopes?: readonly string[] }, @@ -468,10 +463,16 @@ export async function approveDevicePairing( return null; } const approvalRole = resolvePendingApprovalRole(pending); - if (approvalRole && options?.callerScopes) { + if (approvalRole) { const requestedOperatorScopes = normalizeDeviceAuthScopes(pending.scopes).filter((scope) => scope.startsWith(OPERATOR_SCOPE_PREFIX), ); + if (!options?.callerScopes) { + return { + status: "forbidden", + missingScope: requestedOperatorScopes[0] ?? "callerScopes-required", + }; + } const missingScope = resolveMissingRequestedScope({ role: approvalRole, requestedScopes: requestedOperatorScopes,
e403decb6e20https://github.com/openclaw/openclawvia nvd-ref
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- github.com/openclaw/openclaw/commit/e403decb6e20091b5402780a7ccd2085f98aa3cdnvdPatch
- github.com/advisories/GHSA-hc5h-pmr3-3497ghsaADVISORY
- github.com/openclaw/openclaw/security/advisories/GHSA-hc5h-pmr3-3497nvdVendor AdvisoryWEB
- nvd.nist.gov/vuln/detail/CVE-2026-33579ghsaADVISORY
- www.vulncheck.com/advisories/openclaw-privilege-escalation-via-missing-caller-scope-validation-in-device-pair-approvalnvdThird Party AdvisoryWEB
- github.com/openclaw/openclaw/commit/4ee4960de2330b5322127f925f3687dc6f105be1ghsaWEB
News mentions
0No linked articles in our index yet.