VYPR
Critical severity9.1NVD Advisory· Published Apr 28, 2026· Updated May 1, 2026

CVE-2026-41386

CVE-2026-41386

Description

OpenClaw before 2026.3.22 contains a privilege escalation vulnerability where bootstrap setup codes are not bound to intended device roles and scopes during pairing. Attackers can exploit this during first-use device pairing to escalate privileges beyond their intended role and scope.

Affected packages

Versions sourced from the GitHub Security Advisory.

PackageAffected versionsPatched versions
openclawnpm
< 2026.3.222026.3.22

Affected products

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

Patches

1
a600c72ed7d0

fix: bind bootstrap setup codes to node profile

https://github.com/openclaw/openclawPeter SteinbergerMar 23, 2026via ghsa
8 files changed · +117 9
  • CHANGELOG.md+1 0 modified
    @@ -85,6 +85,7 @@ Docs: https://docs.openclaw.ai
     
     ### Fixes
     
    +- Security/pairing: bind iOS setup codes to the intended node profile and reject first-use bootstrap redemption that asks for broader roles or scopes. Thanks @tdjackey.
     - Web tools/Exa: align the bundled Exa plugin with the current Exa API by supporting newer search types and richer `contents` options, while fixing the result-count cap to honor Exa's higher limit. Thanks @vincentkoc.
     - Plugins/Matrix: move bundled plugin `KeyedAsyncQueue` imports onto the stable `plugin-sdk/core` surface so Matrix Docker/runtime builds do not depend on the brittle keyed-async-queue subpath. Thanks @ecohash-co and @vincentkoc.
     - Nostr/security: enforce inbound DM policy before decrypt, route Nostr DMs through the standard reply pipeline, and add pre-crypto rate and size guards so unknown senders cannot bypass pairing or force unbounded crypto work. Thanks @kuranikaran.
    
  • extensions/device-pair/index.test.ts+4 0 modified
    @@ -149,6 +149,10 @@ describe("device-pair /pair qr", () => {
         const text = requireText(result);
     
         expect(pluginApiMocks.renderQrPngBase64).toHaveBeenCalledTimes(1);
    +    expect(pluginApiMocks.issueDeviceBootstrapToken).toHaveBeenCalledWith({
    +      roles: ["node"],
    +      scopes: [],
    +    });
         expect(text).toContain("Scan this QR code with the OpenClaw iOS app:");
         expect(text).toContain("![OpenClaw pairing QR](data:image/png;base64,ZmFrZXBuZw==)");
         expect(text).toContain("- Security: single-use bootstrap token");
    
  • extensions/device-pair/index.ts+6 1 modified
    @@ -43,6 +43,8 @@ function formatDurationMinutes(expiresAtMs: number): string {
     }
     
     const DEFAULT_GATEWAY_PORT = 18789;
    +const SETUP_CODE_ROLES = ["node"] as const;
    +const SETUP_CODE_SCOPES: string[] = [];
     
     type DevicePairPluginConfig = {
       publicUrl?: string;
    @@ -515,7 +517,10 @@ function resolveQrReplyTarget(ctx: QrCommandContext): string {
     }
     
     async function issueSetupPayload(url: string): Promise<SetupPayload> {
    -  const issuedBootstrap = await issueDeviceBootstrapToken();
    +  const issuedBootstrap = await issueDeviceBootstrapToken({
    +    roles: SETUP_CODE_ROLES,
    +    scopes: SETUP_CODE_SCOPES,
    +  });
       return {
         url,
         bootstrapToken: issuedBootstrap.token,
    
  • src/infra/device-bootstrap.test.ts+44 6 modified
    @@ -26,8 +26,8 @@ async function verifyBootstrapToken(
         token,
         deviceId: "device-123",
         publicKey: "public-key-123",
    -    role: "operator.admin",
    -    scopes: ["operator.admin"],
    +    role: "node",
    +    scopes: [],
         baseDir,
         ...overrides,
       });
    @@ -58,6 +58,8 @@ describe("device bootstrap tokens", () => {
           token: issued.token,
           ts: Date.now(),
           issuedAtMs: Date.now(),
    +      roles: ["node"],
    +      scopes: [],
         });
       });
     
    @@ -124,6 +126,8 @@ describe("device bootstrap tokens", () => {
                 token: issued.token,
                 ts: issuedAtMs,
                 issuedAtMs,
    +            roles: ["node"],
    +            scopes: [],
               },
             },
             null,
    @@ -151,6 +155,37 @@ describe("device bootstrap tokens", () => {
         expect(raw).toContain(issued.token);
       });
     
    +  it("rejects bootstrap verification when role or scopes exceed the issued profile", async () => {
    +    const baseDir = await createTempDir();
    +    const issued = await issueDeviceBootstrapToken({ baseDir });
    +
    +    await expect(
    +      verifyBootstrapToken(baseDir, issued.token, {
    +        role: "operator",
    +        scopes: ["operator.admin"],
    +      }),
    +    ).resolves.toEqual({ ok: false, reason: "bootstrap_token_invalid" });
    +
    +    const raw = await fs.readFile(resolveBootstrapPath(baseDir), "utf8");
    +    expect(raw).toContain(issued.token);
    +  });
    +
    +  it("supports explicitly bound bootstrap profiles", async () => {
    +    const baseDir = await createTempDir();
    +    const issued = await issueDeviceBootstrapToken({
    +      baseDir,
    +      roles: ["operator"],
    +      scopes: ["operator.read"],
    +    });
    +
    +    await expect(
    +      verifyBootstrapToken(baseDir, issued.token, {
    +        role: "operator",
    +        scopes: ["operator.read"],
    +      }),
    +    ).resolves.toEqual({ ok: true });
    +  });
    +
       it("accepts trimmed bootstrap tokens and still consumes them once", async () => {
         const baseDir = await createTempDir();
         const issued = await issueDeviceBootstrapToken({ baseDir });
    @@ -176,8 +211,8 @@ describe("device bootstrap tokens", () => {
             token: "missing-token",
             deviceId: "device-123",
             publicKey: "public-key-123",
    -        role: "operator.admin",
    -        scopes: ["operator.admin"],
    +        role: "node",
    +        scopes: [],
             baseDir,
           }),
         ).resolves.toEqual({ ok: false, reason: "bootstrap_token_invalid" });
    @@ -200,7 +235,7 @@ describe("device bootstrap tokens", () => {
         expect(parsed[issued.token]?.token).toBe(issued.token);
       });
     
    -  it("accepts legacy records that only stored issuedAtMs and prunes expired tokens", async () => {
    +  it("fails closed for unbound legacy records and prunes expired tokens", async () => {
         vi.useFakeTimers();
         const baseDir = await createTempDir();
         const bootstrapPath = resolveBootstrapPath(baseDir);
    @@ -226,7 +261,10 @@ describe("device bootstrap tokens", () => {
           "utf8",
         );
     
    -    await expect(verifyBootstrapToken(baseDir, "legacyToken")).resolves.toEqual({ ok: true });
    +    await expect(verifyBootstrapToken(baseDir, "legacyToken")).resolves.toEqual({
    +      ok: false,
    +      reason: "bootstrap_token_invalid",
    +    });
     
         await expect(verifyBootstrapToken(baseDir, "expiredToken")).resolves.toEqual({
           ok: false,
    
  • src/infra/device-bootstrap.ts+42 1 modified
    @@ -1,4 +1,5 @@
     import path from "node:path";
    +import { normalizeDeviceAuthScopes } from "../shared/device-auth.js";
     import { resolvePairingPaths } from "./pairing-files.js";
     import {
       createAsyncLock,
    @@ -25,6 +26,27 @@ type DeviceBootstrapStateFile = Record<string, DeviceBootstrapTokenRecord>;
     
     const withLock = createAsyncLock();
     
    +function normalizeBootstrapRoles(roles: readonly string[] | undefined): string[] {
    +  if (!Array.isArray(roles)) {
    +    return [];
    +  }
    +  const out = new Set<string>();
    +  for (const role of roles) {
    +    const trimmed = role.trim();
    +    if (trimmed) {
    +      out.add(trimmed);
    +    }
    +  }
    +  return [...out].toSorted();
    +}
    +
    +function sameStringSet(left: readonly string[], right: readonly string[]): boolean {
    +  if (left.length !== right.length) {
    +    return false;
    +  }
    +  return left.every((value, index) => value === right[index]);
    +}
    +
     function resolveBootstrapPath(baseDir?: string): string {
       return path.join(resolvePairingPaths(baseDir, "devices").dir, "bootstrap.json");
     }
    @@ -63,15 +85,21 @@ async function persistState(state: DeviceBootstrapStateFile, baseDir?: string):
     export async function issueDeviceBootstrapToken(
       params: {
         baseDir?: string;
    +    roles?: readonly string[];
    +    scopes?: readonly string[];
       } = {},
     ): Promise<{ token: string; expiresAtMs: number }> {
       return await withLock(async () => {
         const state = await loadState(params.baseDir);
         const token = generatePairingToken();
         const issuedAtMs = Date.now();
    +    const roles = normalizeBootstrapRoles(params.roles ?? ["node"]);
    +    const scopes = normalizeDeviceAuthScopes(params.scopes ? [...params.scopes] : []);
         state[token] = {
           token,
           ts: issuedAtMs,
    +      roles,
    +      scopes,
           issuedAtMs,
         };
         await persistState(state, params.baseDir);
    @@ -134,14 +162,27 @@ export async function verifyDeviceBootstrapToken(params: {
         if (!found) {
           return { ok: false, reason: "bootstrap_token_invalid" };
         }
    -    const [tokenKey] = found;
    +    const [tokenKey, record] = found;
     
         const deviceId = params.deviceId.trim();
         const publicKey = params.publicKey.trim();
         const role = params.role.trim();
         if (!deviceId || !publicKey || !role) {
           return { ok: false, reason: "bootstrap_token_invalid" };
         }
    +    const requestedRoles = normalizeBootstrapRoles([role]);
    +    const requestedScopes = normalizeDeviceAuthScopes([...params.scopes]);
    +    const allowedRoles = normalizeBootstrapRoles(record.roles);
    +    const allowedScopes = normalizeDeviceAuthScopes(record.scopes);
    +    // Fail closed for unbound legacy setup codes and for any attempt to redeem
    +    // the token outside the exact role/scope profile it was issued for.
    +    if (
    +      allowedRoles.length === 0 ||
    +      !sameStringSet(requestedRoles, allowedRoles) ||
    +      !sameStringSet(requestedScopes, allowedScopes)
    +    ) {
    +      return { ok: false, reason: "bootstrap_token_invalid" };
    +    }
     
         // Bootstrap setup codes are single-use. Consume the record before returning
         // success so the same token cannot be replayed to mutate a pending request.
    
  • src/infra/device-pairing.test.ts+5 1 modified
    @@ -196,7 +196,11 @@ describe("device pairing tokens", () => {
     
       test("rejects bootstrap token replay before pending scope escalation can be approved", async () => {
         const baseDir = await mkdtemp(join(tmpdir(), "openclaw-device-pairing-"));
    -    const issued = await issueDeviceBootstrapToken({ baseDir });
    +    const issued = await issueDeviceBootstrapToken({
    +      baseDir,
    +      roles: ["operator"],
    +      scopes: ["operator.read"],
    +    });
     
         await expect(
           verifyDeviceBootstrapToken({
    
  • src/pairing/setup-code.test.ts+10 0 modified
    @@ -10,6 +10,7 @@ vi.mock("../infra/device-bootstrap.js", () => ({
     
     let encodePairingSetupCode: typeof import("./setup-code.js").encodePairingSetupCode;
     let resolvePairingSetupFromConfig: typeof import("./setup-code.js").resolvePairingSetupFromConfig;
    +let issueDeviceBootstrapTokenMock: typeof import("../infra/device-bootstrap.js").issueDeviceBootstrapToken;
     
     describe("pairing setup code", () => {
       type ResolvedSetup = Awaited<ReturnType<typeof resolvePairingSetupFromConfig>>;
    @@ -53,6 +54,12 @@ describe("pairing setup code", () => {
         }
         expect(resolved.authLabel).toBe(params.authLabel);
         expect(resolved.payload.bootstrapToken).toBe("bootstrap-123");
    +    expect(issueDeviceBootstrapTokenMock).toHaveBeenCalledWith(
    +      expect.objectContaining({
    +        roles: ["node"],
    +        scopes: [],
    +      }),
    +    );
         if (params.url) {
           expect(resolved.payload.url).toBe(params.url);
         }
    @@ -78,6 +85,9 @@ describe("pairing setup code", () => {
     
       beforeEach(async () => {
         ({ encodePairingSetupCode, resolvePairingSetupFromConfig } = await import("./setup-code.js"));
    +    ({ issueDeviceBootstrapToken: issueDeviceBootstrapTokenMock } =
    +      await import("../infra/device-bootstrap.js"));
    +    vi.mocked(issueDeviceBootstrapTokenMock).mockClear();
       });
     
       afterEach(() => {
    
  • src/pairing/setup-code.ts+5 0 modified
    @@ -22,6 +22,9 @@ export type PairingSetupPayload = {
       bootstrapToken: string;
     };
     
    +const PAIRING_SETUP_BOOTSTRAP_ROLES = ["node"] as const;
    +const PAIRING_SETUP_BOOTSTRAP_SCOPES: string[] = [];
    +
     export type PairingSetupCommandResult = {
       code: number | null;
       stdout: string;
    @@ -384,6 +387,8 @@ export async function resolvePairingSetupFromConfig(
           bootstrapToken: (
             await issueDeviceBootstrapToken({
               baseDir: options.pairingBaseDir,
    +          roles: PAIRING_SETUP_BOOTSTRAP_ROLES,
    +          scopes: PAIRING_SETUP_BOOTSTRAP_SCOPES,
             })
           ).token,
         },
    

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

5

News mentions

0

No linked articles in our index yet.