mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 06:00:43 +00:00
fix(device-pairing): validate callerScopes against resolved token scopes on repair [AI] (#72925)
* fix: address issue * docs: add changelog entry for PR merge
This commit is contained in:
committed by
GitHub
parent
037f197684
commit
189c91eae6
@@ -10,6 +10,7 @@ Docs: https://docs.openclaw.ai
|
||||
|
||||
### Fixes
|
||||
|
||||
- fix(device-pairing): validate callerScopes against resolved token scopes on repair [AI]. (#72925) Thanks @pgondhi987.
|
||||
- fix(agents): canonicalize provider aliases in byProvider tool policy lookup [AI]. (#72917) Thanks @pgondhi987.
|
||||
- fix(security): block npm_execpath injection from workspace .env [AI-assisted]. (#73262) Thanks @pgondhi987.
|
||||
- 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.
|
||||
|
||||
@@ -681,6 +681,41 @@ describe("device pairing tokens", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
test("rejects repair without requested scopes when caller cannot approve inherited token scopes", async () => {
|
||||
const baseDir = await makeDevicePairingDir();
|
||||
await setupPairedOperatorDevice(baseDir, ["operator.admin"]);
|
||||
const before = await getPairedDevice("device-1", baseDir);
|
||||
|
||||
const repair = await requestDevicePairing(
|
||||
{
|
||||
deviceId: "device-1",
|
||||
publicKey: "public-key-1",
|
||||
role: "operator",
|
||||
},
|
||||
baseDir,
|
||||
);
|
||||
|
||||
await expect(
|
||||
approveDevicePairing(
|
||||
repair.request.requestId,
|
||||
{ callerScopes: ["operator.pairing"] },
|
||||
baseDir,
|
||||
),
|
||||
).resolves.toEqual({
|
||||
status: "forbidden",
|
||||
reason: "caller-missing-scope",
|
||||
scope: "operator.admin",
|
||||
});
|
||||
|
||||
const after = await getPairedDevice("device-1", baseDir);
|
||||
expect(after?.tokens?.operator?.token).toEqual(before?.tokens?.operator?.token);
|
||||
expect(after?.tokens?.operator?.scopes).toEqual([
|
||||
"operator.admin",
|
||||
"operator.read",
|
||||
"operator.write",
|
||||
]);
|
||||
});
|
||||
|
||||
test("rejects scope escalation when rotating a token and leaves state unchanged", async () => {
|
||||
const baseDir = await makeDevicePairingDir();
|
||||
await setupPairedOperatorDevice(baseDir, ["operator.read"]);
|
||||
|
||||
@@ -593,26 +593,6 @@ export async function approveDevicePairing(
|
||||
scope: roleMismatchScope,
|
||||
};
|
||||
}
|
||||
const requestedOperatorScopes = requestedScopes.filter((scope) =>
|
||||
scope.startsWith(OPERATOR_SCOPE_PREFIX),
|
||||
);
|
||||
if (requestedOperatorScopes.length > 0) {
|
||||
if (!options?.callerScopes) {
|
||||
return {
|
||||
status: "forbidden",
|
||||
reason: "caller-scopes-required",
|
||||
scope: requestedOperatorScopes[0],
|
||||
};
|
||||
}
|
||||
const missingScope = resolveMissingRequestedScope({
|
||||
role: OPERATOR_ROLE,
|
||||
requestedScopes: requestedOperatorScopes,
|
||||
allowedScopes: options.callerScopes,
|
||||
});
|
||||
if (missingScope) {
|
||||
return { status: "forbidden", reason: "caller-missing-scope", scope: missingScope };
|
||||
}
|
||||
}
|
||||
const now = Date.now();
|
||||
const existing = state.pairedByDeviceId[pending.deviceId];
|
||||
const roles = mergeRoles(existing?.roles, existing?.role, pending.roles, pending.role);
|
||||
@@ -621,6 +601,7 @@ export async function approveDevicePairing(
|
||||
pending.scopes,
|
||||
);
|
||||
const tokens = existing?.tokens ? { ...existing.tokens } : {};
|
||||
const nextTokenScopesByRole = new Map<string, string[]>();
|
||||
for (const roleForToken of requestedRoles) {
|
||||
const existingToken = tokens[roleForToken];
|
||||
const nextScopes = resolveApprovedTokenScopes({
|
||||
@@ -630,13 +611,34 @@ export async function approveDevicePairing(
|
||||
approvedScopes,
|
||||
existing,
|
||||
});
|
||||
const now = Date.now();
|
||||
nextTokenScopesByRole.set(roleForToken, nextScopes);
|
||||
if (roleForToken === OPERATOR_ROLE && nextScopes.length > 0) {
|
||||
if (!options?.callerScopes) {
|
||||
return {
|
||||
status: "forbidden",
|
||||
reason: "caller-scopes-required",
|
||||
scope: nextScopes[0],
|
||||
};
|
||||
}
|
||||
const missingScope = resolveMissingRequestedScope({
|
||||
role: OPERATOR_ROLE,
|
||||
requestedScopes: nextScopes,
|
||||
allowedScopes: options.callerScopes,
|
||||
});
|
||||
if (missingScope) {
|
||||
return { status: "forbidden", reason: "caller-missing-scope", scope: missingScope };
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const [roleForToken, nextScopes] of nextTokenScopesByRole) {
|
||||
const existingToken = tokens[roleForToken];
|
||||
const tokenNow = Date.now();
|
||||
tokens[roleForToken] = {
|
||||
token: newToken(),
|
||||
role: roleForToken,
|
||||
scopes: nextScopes,
|
||||
createdAtMs: existingToken?.createdAtMs ?? now,
|
||||
rotatedAtMs: existingToken ? now : undefined,
|
||||
createdAtMs: existingToken?.createdAtMs ?? tokenNow,
|
||||
rotatedAtMs: existingToken ? tokenNow : undefined,
|
||||
revokedAtMs: undefined,
|
||||
lastUsedAtMs: existingToken?.lastUsedAtMs,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user