fix: bound bootstrap handoff token scopes

This commit is contained in:
Peter Steinberger 2026-04-04 22:28:49 +09:00
parent 7c0752f834
commit 7d22a16adb
No known key found for this signature in database
9 changed files with 127 additions and 8 deletions

View File

@ -103,7 +103,9 @@ Docs: https://docs.openclaw.ai
- Synology Chat/security: default low-level HTTPS helper TLS verification to on so helper/API defaults match the shipped safe account default, and only explicit `allowInsecureSsl: true` opts out.
- Android/canvas security: require exact normalized A2UI URL matches before forwarding canvas bridge actions, rejecting query mismatches and descendant paths while still allowing fragment-only A2UI navigation.
- Cron: send failure notifications through the job's primary delivery channel using the same session context as successful delivery when no explicit `failureDestination` is configured. (#60622) Thanks @artwalker.
- Gateway/auth: serialize async shared-secret auth attempts per client so concurrent Tailscale-capable failures cannot overrun the intended auth rate-limit budget. Thanks @Telecaster2147.- Doctor/config: compare normalized `talk` configs by deep structural equality instead of key-order-sensitive serialization so `openclaw doctor --fix` stops repeatedly reporting/applying no-op `talk.provider/providers` normalization. (#59911) Thanks @ejames-dev.
- Mobile pairing/bootstrap: keep QR bootstrap handoff tokens bounded to the mobile-safe contract so node handoff stays unscoped and operator handoff drops mixed `node.*`, `operator.admin`, and `operator.pairing` scopes.
- Gateway/auth: serialize async shared-secret auth attempts per client so concurrent Tailscale-capable failures cannot overrun the intended auth rate-limit budget. Thanks @Telecaster2147.
- Doctor/config: compare normalized `talk` configs by deep structural equality instead of key-order-sensitive serialization so `openclaw doctor --fix` stops repeatedly reporting/applying no-op `talk.provider/providers` normalization. (#59911) Thanks @ejames-dev.
- 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.
## 2026.4.2

View File

@ -275,8 +275,10 @@ struct GatewayNodeSessionTests {
"deviceToken": "operator-device-token",
"role": "operator",
"scopes": [
"node.exec",
"operator.admin",
"operator.approvals",
"operator.pairing",
"operator.read",
"operator.talk.secrets",
"operator.write",
@ -315,8 +317,12 @@ struct GatewayNodeSessionTests {
#expect(nodeEntry.token == "node-device-token")
#expect(nodeEntry.scopes == [])
#expect(operatorEntry.token == "operator-device-token")
#expect(operatorEntry.scopes.contains("operator.approvals"))
#expect(!operatorEntry.scopes.contains("operator.admin"))
#expect(operatorEntry.scopes == [
"operator.approvals",
"operator.read",
"operator.talk.secrets",
"operator.write",
])
await gateway.disconnect()
}

View File

@ -109,6 +109,11 @@ bounded role entries in `deviceTokens`:
}
```
For the built-in node/operator bootstrap flow, the primary node token stays
`scopes: []` and any handed-off operator token stays bounded to the bootstrap
operator allowlist (`operator.approvals`, `operator.read`,
`operator.talk.secrets`, `operator.write`).
### Node example
```json

View File

@ -780,6 +780,14 @@ export function registerControlUiAndPairingSuite(): void {
"operator.write",
]),
);
expect(
initialPayload?.auth?.deviceTokens?.find((entry) => entry.role === "operator")?.scopes,
).not.toEqual(
expect.arrayContaining(["node.camera", "node.display", "node.exec", "node.voice"]),
);
expect(
initialPayload?.auth?.deviceTokens?.find((entry) => entry.role === "operator")?.scopes,
).not.toEqual(expect.arrayContaining(["operator.admin", "operator.pairing"]));
const afterBootstrap = await listDevicePairing();
expect(

View File

@ -34,7 +34,10 @@ import { upsertPresence } from "../../../infra/system-presence.js";
import { loadVoiceWakeConfig } from "../../../infra/voicewake.js";
import { rawDataToString } from "../../../infra/ws.js";
import type { createSubsystemLogger } from "../../../logging/subsystem.js";
import type { DeviceBootstrapProfile } from "../../../shared/device-bootstrap-profile.js";
import {
resolveBootstrapProfileScopesForRole,
type DeviceBootstrapProfile,
} from "../../../shared/device-bootstrap-profile.js";
import { roleScopesAllow } from "../../../shared/operator-scope-compat.js";
import {
isBrowserOperatorUiClient,
@ -1071,7 +1074,12 @@ export function attachGatewayWsMessageHandler(params: {
continue;
}
const bootstrapRoleScopes =
bootstrapRole === "operator" ? bootstrapProfileForHello.scopes : [];
bootstrapRole === "operator"
? resolveBootstrapProfileScopesForRole(
bootstrapRole,
bootstrapProfileForHello.scopes,
)
: [];
const extraToken = await ensureDeviceToken({
deviceId: device.id,
role: bootstrapRole,

View File

@ -626,6 +626,36 @@ describe("device pairing tokens", () => {
);
});
test("bootstrap pairing keeps operator token scopes operator-only", async () => {
const baseDir = await mkdtemp(join(tmpdir(), "openclaw-device-pairing-"));
const request = await requestDevicePairing(
{
deviceId: "bootstrap-device-operator-scope",
publicKey: "bootstrap-public-key-operator-scope",
role: "node",
roles: ["node", "operator"],
scopes: [],
silent: true,
},
baseDir,
);
await expect(
approveBootstrapDevicePairing(
request.request.requestId,
{
roles: ["node", "operator"],
scopes: ["node.exec", "operator.pairing", "operator.read", "operator.write"],
},
baseDir,
),
).resolves.toEqual(expect.objectContaining({ status: "approved" }));
const paired = await getPairedDevice("bootstrap-device-operator-scope", baseDir);
expect(paired?.tokens?.operator?.scopes).toEqual(["operator.read", "operator.write"]);
expect(paired?.tokens?.node?.scopes).toEqual([]);
});
test("verifies token and rejects mismatches", async () => {
const { baseDir, token } = await setupOperatorToken(["operator.read"]);

View File

@ -1,6 +1,9 @@
import { randomUUID } from "node:crypto";
import { normalizeDeviceAuthScopes } from "../shared/device-auth.js";
import type { DeviceBootstrapProfile } from "../shared/device-bootstrap-profile.js";
import {
resolveBootstrapProfileScopesForRole,
type DeviceBootstrapProfile,
} from "../shared/device-bootstrap-profile.js";
import { resolveMissingRequestedScope, roleScopesAllow } from "../shared/operator-scope-compat.js";
import {
createAsyncLock,
@ -643,7 +646,10 @@ export async function approveBootstrapDevicePairing(
const tokens = existing?.tokens ? { ...existing.tokens } : {};
for (const roleForToken of approvedRoles) {
const existingToken = tokens[roleForToken];
const tokenScopes = roleForToken === OPERATOR_ROLE ? approvedScopes : [];
const tokenScopes =
roleForToken === OPERATOR_ROLE
? resolveBootstrapProfileScopesForRole(roleForToken, approvedScopes)
: [];
tokens[roleForToken] = buildDeviceAuthToken({
role: roleForToken,
scopes: tokenScopes,

View File

@ -0,0 +1,33 @@
import { describe, expect, test } from "vitest";
import {
BOOTSTRAP_HANDOFF_OPERATOR_SCOPES,
resolveBootstrapProfileScopesForRole,
} from "./device-bootstrap-profile.js";
describe("device bootstrap profile", () => {
test("bounds bootstrap handoff scopes by role", () => {
expect(
resolveBootstrapProfileScopesForRole("operator", [
"node.exec",
"operator.admin",
"operator.approvals",
"operator.pairing",
"operator.read",
"operator.write",
]),
).toEqual(["operator.approvals", "operator.read", "operator.write"]);
expect(
resolveBootstrapProfileScopesForRole("node", ["node.exec", "operator.approvals"]),
).toEqual([]);
});
test("bootstrap handoff operator allowlist stays aligned with pairing setup profile", () => {
expect([...BOOTSTRAP_HANDOFF_OPERATOR_SCOPES]).toEqual([
"operator.approvals",
"operator.read",
"operator.talk.secrets",
"operator.write",
]);
});
});

View File

@ -10,11 +10,32 @@ export type DeviceBootstrapProfileInput = {
scopes?: readonly string[];
};
export const BOOTSTRAP_HANDOFF_OPERATOR_SCOPES = [
"operator.approvals",
"operator.read",
"operator.talk.secrets",
"operator.write",
] as const;
const BOOTSTRAP_HANDOFF_OPERATOR_SCOPE_SET = new Set<string>(BOOTSTRAP_HANDOFF_OPERATOR_SCOPES);
export const PAIRING_SETUP_BOOTSTRAP_PROFILE: DeviceBootstrapProfile = {
roles: ["node", "operator"],
scopes: ["operator.approvals", "operator.read", "operator.talk.secrets", "operator.write"],
scopes: [...BOOTSTRAP_HANDOFF_OPERATOR_SCOPES],
};
export function resolveBootstrapProfileScopesForRole(
role: string,
scopes: readonly string[],
): string[] {
const normalizedRole = normalizeDeviceAuthRole(role);
const normalizedScopes = normalizeDeviceAuthScopes(Array.from(scopes));
if (normalizedRole === "operator") {
return normalizedScopes.filter((scope) => BOOTSTRAP_HANDOFF_OPERATOR_SCOPE_SET.has(scope));
}
return [];
}
function normalizeBootstrapRoles(roles: readonly string[] | undefined): string[] {
if (!Array.isArray(roles)) {
return [];