fix: constrain device bootstrap scope checks by role prefix (#57258) (thanks @jlapenna) (#57258)

Co-authored-by: Peter Steinberger <steipete@gmail.com>
This commit is contained in:
Joe LaPenna
2026-04-04 07:21:01 -07:00
committed by GitHub
parent a2e0a094c1
commit bb82fe8f19
4 changed files with 37 additions and 10 deletions

View File

@@ -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

View File

@@ -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({

View File

@@ -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();
});

View File

@@ -16,7 +16,10 @@ function normalizeScopeList(scopes: readonly string[]): string[] {
}
function operatorScopeSatisfied(requestedScope: string, granted: Set<string>): 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));
}