From 9e3f96982eada036c3f55ccd3fa06bb009773aba Mon Sep 17 00:00:00 2001 From: mbelinky Date: Fri, 20 Feb 2026 18:36:15 +0100 Subject: [PATCH] Gateway/Pairing: guard rotate scope escalation openclaw#20703 thanks @coygeek --- CHANGELOG.md | 1 + src/gateway/server-methods/devices.ts | 2 +- src/infra/device-pairing.test.ts | 25 ++++++++++++-- src/infra/device-pairing.ts | 50 +++++++++++++++++++++++---- 4 files changed, 69 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 046c7b4245a..a022137431e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- Gateway/Pairing: prevent device-token rotate scope escalation by enforcing an approved-scope baseline, preserving approved scopes across metadata updates, and rejecting rotate requests that exceed approved role scope implications. (#20703) thanks @coygeek. - Gateway/Security: require secure context and paired-device checks for Control UI auth even when `gateway.controlUi.allowInsecureAuth` is set, and align audit messaging with the hardened behavior. (#20684) thanks @coygeek. - macOS/Build: default release packaging to `BUNDLE_ID=ai.openclaw.mac` in `scripts/package-mac-dist.sh`, so Sparkle feed URL is retained and auto-update no longer fails with an empty appcast feed. (#19750) thanks @loganprit. diff --git a/src/gateway/server-methods/devices.ts b/src/gateway/server-methods/devices.ts index d1011c88dff..98c4938b22c 100644 --- a/src/gateway/server-methods/devices.ts +++ b/src/gateway/server-methods/devices.ts @@ -24,7 +24,7 @@ import type { GatewayRequestHandlers } from "./types.js"; function redactPairedDevice( device: { tokens?: Record } & Record, ) { - const { tokens, ...rest } = device; + const { tokens, approvedScopes: _approvedScopes, ...rest } = device; return { ...rest, tokens: summarizeDeviceTokens(tokens), diff --git a/src/infra/device-pairing.test.ts b/src/infra/device-pairing.test.ts index d0794de7583..11119b2f0ab 100644 --- a/src/infra/device-pairing.test.ts +++ b/src/infra/device-pairing.test.ts @@ -97,7 +97,7 @@ describe("device pairing tokens", () => { expect(Buffer.from(token, "base64url")).toHaveLength(32); }); - test("preserves existing token scopes when rotating without scopes", async () => { + test("allows down-scoping from admin and preserves approved scope baseline", async () => { const baseDir = await mkdtemp(join(tmpdir(), "openclaw-device-pairing-")); await setupPairedOperatorDevice(baseDir, ["operator.admin"]); @@ -109,7 +109,8 @@ describe("device pairing tokens", () => { }); let paired = await getPairedDevice("device-1", baseDir); expect(paired?.tokens?.operator?.scopes).toEqual(["operator.read"]); - expect(paired?.scopes).toEqual(["operator.read"]); + expect(paired?.scopes).toEqual(["operator.admin"]); + expect(paired?.approvedScopes).toEqual(["operator.admin"]); await rotateDeviceToken({ deviceId: "device-1", @@ -120,6 +121,26 @@ describe("device pairing tokens", () => { expect(paired?.tokens?.operator?.scopes).toEqual(["operator.read"]); }); + test("rejects scope escalation when rotating a token and leaves state unchanged", async () => { + const baseDir = await mkdtemp(join(tmpdir(), "openclaw-device-pairing-")); + await setupPairedOperatorDevice(baseDir, ["operator.read"]); + const before = await getPairedDevice("device-1", baseDir); + + const rotated = await rotateDeviceToken({ + deviceId: "device-1", + role: "operator", + scopes: ["operator.admin"], + baseDir, + }); + expect(rotated).toBeNull(); + + const after = await getPairedDevice("device-1", baseDir); + expect(after?.tokens?.operator?.token).toEqual(before?.tokens?.operator?.token); + expect(after?.tokens?.operator?.scopes).toEqual(["operator.read"]); + expect(after?.scopes).toEqual(["operator.read"]); + expect(after?.approvedScopes).toEqual(["operator.read"]); + }); + test("verifies token and rejects mismatches", async () => { const baseDir = await mkdtemp(join(tmpdir(), "openclaw-device-pairing-")); await setupPairedOperatorDevice(baseDir, ["operator.read"]); diff --git a/src/infra/device-pairing.ts b/src/infra/device-pairing.ts index 313ee54c90a..69cd4cfc461 100644 --- a/src/infra/device-pairing.ts +++ b/src/infra/device-pairing.ts @@ -56,6 +56,7 @@ export type PairedDevice = { role?: string; roles?: string[]; scopes?: string[]; + approvedScopes?: string[]; remoteIp?: string; tokens?: Record; createdAtMs: number; @@ -176,6 +177,33 @@ function mergePendingDevicePairingRequest( }; } +const DEVICE_SCOPE_IMPLICATIONS: Readonly> = { + "operator.admin": ["operator.read", "operator.write", "operator.approvals", "operator.pairing"], + "operator.write": ["operator.read"], +}; + +function expandScopeImplications(scopes: string[]): string[] { + const expanded = new Set(scopes); + const queue = [...scopes]; + while (queue.length > 0) { + const scope = queue.pop(); + if (!scope) { + continue; + } + for (const impliedScope of DEVICE_SCOPE_IMPLICATIONS[scope] ?? []) { + if (!expanded.has(impliedScope)) { + expanded.add(impliedScope); + queue.push(impliedScope); + } + } + } + return [...expanded]; +} + +function scopesAllowWithImplications(requested: string[], allowed: string[]): boolean { + return scopesAllow(expandScopeImplications(requested), expandScopeImplications(allowed)); +} + function newToken() { return generatePairingToken(); } @@ -286,7 +314,10 @@ export async function approveDevicePairing( const now = Date.now(); const existing = state.pairedByDeviceId[pending.deviceId]; const roles = mergeRoles(existing?.roles, existing?.role, pending.roles, pending.role); - const scopes = mergeScopes(existing?.scopes, pending.scopes); + const approvedScopes = mergeScopes( + existing?.approvedScopes ?? existing?.scopes, + pending.scopes, + ); const tokens = existing?.tokens ? { ...existing.tokens } : {}; const roleForToken = normalizeRole(pending.role); if (roleForToken) { @@ -312,7 +343,8 @@ export async function approveDevicePairing( clientMode: pending.clientMode, role: pending.role, roles, - scopes, + scopes: approvedScopes, + approvedScopes, remoteIp: pending.remoteIp, tokens, createdAtMs: existing?.createdAtMs ?? now, @@ -359,7 +391,9 @@ export async function removePairedDevice( export async function updatePairedDeviceMetadata( deviceId: string, - patch: Partial>, + patch: Partial< + Omit + >, baseDir?: string, ): Promise { return await withLock(async () => { @@ -376,6 +410,7 @@ export async function updatePairedDeviceMetadata( deviceId: existing.deviceId, createdAtMs: existing.createdAtMs, approvedAtMs: existing.approvedAtMs, + approvedScopes: existing.approvedScopes, role: patch.role ?? existing.role, roles, scopes, @@ -525,6 +560,12 @@ export async function rotateDeviceToken(params: { const requestedScopes = normalizeDeviceAuthScopes( params.scopes ?? existing?.scopes ?? device.scopes, ); + const approvedScopes = normalizeDeviceAuthScopes( + device.approvedScopes ?? device.scopes ?? existing?.scopes, + ); + if (!scopesAllowWithImplications(requestedScopes, approvedScopes)) { + return null; + } const now = Date.now(); const next = buildDeviceAuthToken({ role, @@ -535,9 +576,6 @@ export async function rotateDeviceToken(params: { }); tokens[role] = next; device.tokens = tokens; - if (params.scopes !== undefined) { - device.scopes = requestedScopes; - } state.pairedByDeviceId[device.deviceId] = device; await persistState(state, params.baseDir); return next;