From bb82fe8f19dc09008209cb3abfe3cc88a7613cbb Mon Sep 17 00:00:00 2001 From: Joe LaPenna Date: Sat, 4 Apr 2026 07:21:01 -0700 Subject: [PATCH] fix: constrain device bootstrap scope checks by role prefix (#57258) (thanks @jlapenna) (#57258) Co-authored-by: Peter Steinberger --- CHANGELOG.md | 1 + src/infra/device-bootstrap.test.ts | 15 +++++++++++++++ src/shared/operator-scope-compat.test.ts | 23 +++++++++++++++-------- src/shared/operator-scope-compat.ts | 8 ++++++-- 4 files changed, 37 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9190b06efd4..3c0e0c3d52d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -110,6 +110,7 @@ Docs: https://docs.openclaw.ai - Gateway/device auth: reuse cached device-token scopes only for cached-token reconnects, while keeping explicit `deviceToken` scope requests and empty-cache fallbacks intact so reconnects preserve `operator.read` without breaking explicit auth flows. (#46032) Thanks @caicongyang. - Mattermost/config schema: accept `groups.*.requireMention` again so existing Mattermost configs no longer fail strict validation after upgrade. (#58271) Thanks @MoerAI. - Providers/OpenRouter failover: classify `403 "Key limit exceeded"` spending-limit responses as billing so model fallback continues instead of stopping on generic auth. (#59892) Thanks @rockcent. +- Device pairing/security: keep non-operator device scope checks bound to the requested role prefix so bootstrap verification cannot redeem `operator.*` scopes through `node` auth. (#57258) Thanks @jlapenna. ## 2026.4.2 diff --git a/src/infra/device-bootstrap.test.ts b/src/infra/device-bootstrap.test.ts index 813f7c3abc8..9f8b379e8b2 100644 --- a/src/infra/device-bootstrap.test.ts +++ b/src/infra/device-bootstrap.test.ts @@ -286,6 +286,21 @@ describe("device bootstrap tokens", () => { ).resolves.toEqual({ ok: true }); }); + it("rejects cross-role scope escalation (node role requesting operator scopes)", async () => { + const baseDir = await createTempDir(); + const issued = await issueDeviceBootstrapToken({ baseDir }); + + await expect( + verifyBootstrapToken(baseDir, issued.token, { + role: "node", + scopes: ["operator.read"], + }), + ).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({ diff --git a/src/shared/operator-scope-compat.test.ts b/src/shared/operator-scope-compat.test.ts index 895b9665d12..f697ba423e5 100644 --- a/src/shared/operator-scope-compat.test.ts +++ b/src/shared/operator-scope-compat.test.ts @@ -80,26 +80,33 @@ describe("roleScopesAllow", () => { ).toBe(false); }); - it("uses strict matching for non-operator roles", () => { + it("uses strict matching with role-prefix partitioning for non-operator roles", () => { expect( roleScopesAllow({ role: "node", - requestedScopes: ["system.run"], - allowedScopes: ["operator.admin", "system.run"], + requestedScopes: ["node.exec"], + allowedScopes: ["operator.admin", "node.exec"], }), ).toBe(true); expect( roleScopesAllow({ role: "node", - requestedScopes: ["system.run"], + requestedScopes: ["node.exec"], allowedScopes: ["operator.admin"], }), ).toBe(false); + expect( + roleScopesAllow({ + role: "node", + requestedScopes: ["operator.read"], + allowedScopes: ["operator.read", "node.exec"], + }), + ).toBe(false); expect( roleScopesAllow({ role: " node ", - requestedScopes: [" system.run ", "system.run", " "], - allowedScopes: ["system.run", "operator.admin"], + requestedScopes: [" node.exec ", "node.exec", " "], + allowedScopes: ["node.exec", "operator.admin"], }), ).toBe(true); }); @@ -145,8 +152,8 @@ describe("roleScopesAllow", () => { expect( resolveMissingRequestedScope({ role: "node", - requestedScopes: ["system.run"], - allowedScopes: ["system.run", "operator.admin"], + requestedScopes: ["node.exec"], + allowedScopes: ["node.exec", "operator.admin"], }), ).toBeNull(); }); diff --git a/src/shared/operator-scope-compat.ts b/src/shared/operator-scope-compat.ts index cf184558caa..82e70a97d4b 100644 --- a/src/shared/operator-scope-compat.ts +++ b/src/shared/operator-scope-compat.ts @@ -16,7 +16,10 @@ function normalizeScopeList(scopes: readonly string[]): string[] { } function operatorScopeSatisfied(requestedScope: string, granted: Set): boolean { - if (granted.has(OPERATOR_ADMIN_SCOPE) && requestedScope.startsWith(OPERATOR_SCOPE_PREFIX)) { + if (!requestedScope.startsWith(OPERATOR_SCOPE_PREFIX)) { + return false; + } + if (granted.has(OPERATOR_ADMIN_SCOPE)) { return true; } if (requestedScope === OPERATOR_READ_SCOPE) { @@ -43,7 +46,8 @@ export function roleScopesAllow(params: { } const allowedSet = new Set(allowed); if (params.role.trim() !== OPERATOR_ROLE) { - return requested.every((scope) => allowedSet.has(scope)); + const prefix = `${params.role.trim()}.`; + return requested.every((scope) => scope.startsWith(prefix) && allowedSet.has(scope)); } return requested.every((scope) => operatorScopeSatisfied(scope, allowedSet)); }