Skip to content

Commit 189c91e

Browse files
authored
fix(device-pairing): validate callerScopes against resolved token scopes on repair [AI] (#72925)
* fix: address issue * docs: add changelog entry for PR merge
1 parent 037f197 commit 189c91e

3 files changed

Lines changed: 61 additions & 23 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ Docs: https://docs.openclaw.ai
1010

1111
### Fixes
1212

13+
- fix(device-pairing): validate callerScopes against resolved token scopes on repair [AI]. (#72925) Thanks @pgondhi987.
1314
- fix(agents): canonicalize provider aliases in byProvider tool policy lookup [AI]. (#72917) Thanks @pgondhi987.
1415
- fix(security): block npm_execpath injection from workspace .env [AI-assisted]. (#73262) Thanks @pgondhi987.
1516
- Tools/web_fetch: decode response bodies from raw bytes using declared HTTP, XML, or HTML meta charsets before extraction, so Shift_JIS and other legacy-charset pages no longer return mojibake. Fixes #72916. Thanks @amknight.

src/infra/device-pairing.test.ts

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -681,6 +681,41 @@ describe("device pairing tokens", () => {
681681
]);
682682
});
683683

684+
test("rejects repair without requested scopes when caller cannot approve inherited token scopes", async () => {
685+
const baseDir = await makeDevicePairingDir();
686+
await setupPairedOperatorDevice(baseDir, ["operator.admin"]);
687+
const before = await getPairedDevice("device-1", baseDir);
688+
689+
const repair = await requestDevicePairing(
690+
{
691+
deviceId: "device-1",
692+
publicKey: "public-key-1",
693+
role: "operator",
694+
},
695+
baseDir,
696+
);
697+
698+
await expect(
699+
approveDevicePairing(
700+
repair.request.requestId,
701+
{ callerScopes: ["operator.pairing"] },
702+
baseDir,
703+
),
704+
).resolves.toEqual({
705+
status: "forbidden",
706+
reason: "caller-missing-scope",
707+
scope: "operator.admin",
708+
});
709+
710+
const after = await getPairedDevice("device-1", baseDir);
711+
expect(after?.tokens?.operator?.token).toEqual(before?.tokens?.operator?.token);
712+
expect(after?.tokens?.operator?.scopes).toEqual([
713+
"operator.admin",
714+
"operator.read",
715+
"operator.write",
716+
]);
717+
});
718+
684719
test("rejects scope escalation when rotating a token and leaves state unchanged", async () => {
685720
const baseDir = await makeDevicePairingDir();
686721
await setupPairedOperatorDevice(baseDir, ["operator.read"]);

src/infra/device-pairing.ts

Lines changed: 25 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -593,26 +593,6 @@ export async function approveDevicePairing(
593593
scope: roleMismatchScope,
594594
};
595595
}
596-
const requestedOperatorScopes = requestedScopes.filter((scope) =>
597-
scope.startsWith(OPERATOR_SCOPE_PREFIX),
598-
);
599-
if (requestedOperatorScopes.length > 0) {
600-
if (!options?.callerScopes) {
601-
return {
602-
status: "forbidden",
603-
reason: "caller-scopes-required",
604-
scope: requestedOperatorScopes[0],
605-
};
606-
}
607-
const missingScope = resolveMissingRequestedScope({
608-
role: OPERATOR_ROLE,
609-
requestedScopes: requestedOperatorScopes,
610-
allowedScopes: options.callerScopes,
611-
});
612-
if (missingScope) {
613-
return { status: "forbidden", reason: "caller-missing-scope", scope: missingScope };
614-
}
615-
}
616596
const now = Date.now();
617597
const existing = state.pairedByDeviceId[pending.deviceId];
618598
const roles = mergeRoles(existing?.roles, existing?.role, pending.roles, pending.role);
@@ -621,6 +601,7 @@ export async function approveDevicePairing(
621601
pending.scopes,
622602
);
623603
const tokens = existing?.tokens ? { ...existing.tokens } : {};
604+
const nextTokenScopesByRole = new Map<string, string[]>();
624605
for (const roleForToken of requestedRoles) {
625606
const existingToken = tokens[roleForToken];
626607
const nextScopes = resolveApprovedTokenScopes({
@@ -630,13 +611,34 @@ export async function approveDevicePairing(
630611
approvedScopes,
631612
existing,
632613
});
633-
const now = Date.now();
614+
nextTokenScopesByRole.set(roleForToken, nextScopes);
615+
if (roleForToken === OPERATOR_ROLE && nextScopes.length > 0) {
616+
if (!options?.callerScopes) {
617+
return {
618+
status: "forbidden",
619+
reason: "caller-scopes-required",
620+
scope: nextScopes[0],
621+
};
622+
}
623+
const missingScope = resolveMissingRequestedScope({
624+
role: OPERATOR_ROLE,
625+
requestedScopes: nextScopes,
626+
allowedScopes: options.callerScopes,
627+
});
628+
if (missingScope) {
629+
return { status: "forbidden", reason: "caller-missing-scope", scope: missingScope };
630+
}
631+
}
632+
}
633+
for (const [roleForToken, nextScopes] of nextTokenScopesByRole) {
634+
const existingToken = tokens[roleForToken];
635+
const tokenNow = Date.now();
634636
tokens[roleForToken] = {
635637
token: newToken(),
636638
role: roleForToken,
637639
scopes: nextScopes,
638-
createdAtMs: existingToken?.createdAtMs ?? now,
639-
rotatedAtMs: existingToken ? now : undefined,
640+
createdAtMs: existingToken?.createdAtMs ?? tokenNow,
641+
rotatedAtMs: existingToken ? tokenNow : undefined,
640642
revokedAtMs: undefined,
641643
lastUsedAtMs: existingToken?.lastUsedAtMs,
642644
};

0 commit comments

Comments
 (0)