Skip to content

Commit b660493

Browse files
committed
fix: harden device pairing scope approval
1 parent a5de4a1 commit b660493

3 files changed

Lines changed: 83 additions & 15 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,7 @@ Docs: https://docs.openclaw.ai
8383
- Browser/plugin SDK: route browser auth, profile, host-inspection, and doctor readiness helpers through browser plugin public facades so core compatibility helpers stop carrying duplicate runtime implementations. (#63957) Thanks @joshavant.
8484
- Agents/failover: allow cooldown probes for `timeout` (including network outage classifications) so the primary model can recover after failover without a gateway restart. (#63996) Thanks @neeravmakwana.
8585
- iMessage (imsg): strip an accidental protobuf length-delimited UTF-8 field wrapper from inbound `text` and `reply_to_text` when it fully consumes the field, fixing leading garbage before the real message. (#63868) Thanks @neeravmakwana.
86+
- Gateway/pairing: fail closed for paired device records that have no device tokens, and reject pairing approvals whose requested scopes do not match the requested device roles.
8687

8788
## 2026.4.9
8889

src/infra/device-pairing.test.ts

Lines changed: 51 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -284,6 +284,53 @@ describe("device pairing tokens", () => {
284284
).resolves.toEqual({ ok: true });
285285
});
286286

287+
test.each([
288+
{
289+
name: "node custom scope",
290+
roles: ["node"],
291+
scopes: ["vault.admin"],
292+
missingScope: "vault.admin",
293+
callerScopes: [],
294+
},
295+
{
296+
name: "operator custom scope",
297+
roles: ["operator"],
298+
scopes: ["vault.admin"],
299+
missingScope: "vault.admin",
300+
callerScopes: ["operator.pairing"],
301+
},
302+
{
303+
name: "node requesting operator scope",
304+
roles: ["node"],
305+
scopes: ["operator.read"],
306+
missingScope: "operator.read",
307+
callerScopes: ["operator.read"],
308+
},
309+
])("rejects requested scopes outside requested roles: $name", async (params) => {
310+
const baseDir = await makeDevicePairingDir();
311+
const request = await requestDevicePairing(
312+
{
313+
deviceId: "device-1",
314+
publicKey: "public-key-1",
315+
roles: params.roles,
316+
scopes: params.scopes,
317+
},
318+
baseDir,
319+
);
320+
321+
await expect(
322+
approveDevicePairing(
323+
request.request.requestId,
324+
{ callerScopes: params.callerScopes },
325+
baseDir,
326+
),
327+
).resolves.toEqual({
328+
status: "forbidden",
329+
missingScope: params.missingScope,
330+
});
331+
await expect(getPairedDevice("device-1", baseDir)).resolves.toBeNull();
332+
});
333+
287334
test("preserves existing non-operator scopes during operator-only mixed-role repairs", async () => {
288335
const baseDir = await makeDevicePairingDir();
289336
const initial = await requestDevicePairing(
@@ -831,7 +878,7 @@ describe("device pairing tokens", () => {
831878
expect(hasEffectivePairedDeviceRole(paired, "node")).toBe(false);
832879
});
833880

834-
test("falls back to legacy role fields when tokens map is empty", async () => {
881+
test("fails closed for tokenless legacy role fields", async () => {
835882
const device: PairedDevice = {
836883
deviceId: "device-fallback",
837884
publicKey: "pk-fallback",
@@ -841,9 +888,9 @@ describe("device pairing tokens", () => {
841888
createdAtMs: Date.now(),
842889
approvedAtMs: Date.now(),
843890
};
844-
expect(listEffectivePairedDeviceRoles(device)).toEqual(["node", "operator"]);
845-
expect(hasEffectivePairedDeviceRole(device, "node")).toBe(true);
846-
expect(hasEffectivePairedDeviceRole(device, "operator")).toBe(true);
891+
expect(listEffectivePairedDeviceRoles(device)).toEqual([]);
892+
expect(hasEffectivePairedDeviceRole(device, "node")).toBe(false);
893+
expect(hasEffectivePairedDeviceRole(device, "operator")).toBe(false);
847894
});
848895

849896
test("filters active token roles to the approved pairing role set", async () => {

src/infra/device-pairing.ts

Lines changed: 31 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -189,16 +189,9 @@ export function listEffectivePairedDeviceRoles(
189189
const approvedRoles = new Set(listApprovedPairedDeviceRoles(device));
190190
return activeTokenRoles.filter((role) => approvedRoles.has(role));
191191
}
192-
// Only fall back to legacy role fields when the tokens map is absent
193-
// or has no entries at all (empty object from a fresh pairing record).
194-
// When token entries exist but are all revoked, the revocation is
195-
// authoritative — do not re-grant roles from sticky historical fields.
196-
if (device.tokens && Object.keys(device.tokens).length > 0) {
197-
return [];
198-
}
199-
// Legacy fallback: when no token map exists yet, treat the approved pairing
200-
// roles as effective until token issuance has happened.
201-
return listApprovedPairedDeviceRoles(device);
192+
// Token entries are authoritative. Tokenless legacy records fail closed so
193+
// sticky historical role fields cannot retain access after token migration.
194+
return [];
202195
}
203196

204197
export function hasEffectivePairedDeviceRole(
@@ -413,6 +406,25 @@ function scopesWithinApprovedDeviceBaseline(params: {
413406
});
414407
}
415408

409+
function resolveScopeOutsideRequestedRoles(params: {
410+
requestedRoles: readonly string[];
411+
requestedScopes: readonly string[];
412+
}): string | null {
413+
for (const scope of params.requestedScopes) {
414+
const matchesRequestedRole = params.requestedRoles.some((role) =>
415+
roleScopesAllow({
416+
role,
417+
requestedScopes: [scope],
418+
allowedScopes: [scope],
419+
}),
420+
);
421+
if (!matchesRequestedRole) {
422+
return scope;
423+
}
424+
}
425+
return null;
426+
}
427+
416428
export async function listDevicePairing(baseDir?: string): Promise<DevicePairingList> {
417429
const state = await loadState(baseDir);
418430
const pending = Object.values(state.pendingById).toSorted((a, b) => b.ts - a.ts);
@@ -522,7 +534,15 @@ export async function approveDevicePairing(
522534
return null;
523535
}
524536
const requestedRoles = mergeRoles(pending.roles, pending.role) ?? [];
525-
const requestedOperatorScopes = normalizeDeviceAuthScopes(pending.scopes).filter((scope) =>
537+
const requestedScopes = normalizeDeviceAuthScopes(pending.scopes);
538+
const roleMismatchScope = resolveScopeOutsideRequestedRoles({
539+
requestedRoles,
540+
requestedScopes,
541+
});
542+
if (roleMismatchScope) {
543+
return { status: "forbidden", missingScope: roleMismatchScope };
544+
}
545+
const requestedOperatorScopes = requestedScopes.filter((scope) =>
526546
scope.startsWith(OPERATOR_SCOPE_PREFIX),
527547
);
528548
if (requestedOperatorScopes.length > 0) {

0 commit comments

Comments
 (0)