VYPR
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.

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

2
4ee4960de233

Pairing: forward caller scopes during approval (#55950)

https://github.com/openclaw/openclawJacob TomlinsonMar 27, 2026via ghsa
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,
    

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.