Skip to content

Commit b8372a7

Browse files
eleqtrizitzsxsoftdrobison00
authored
fix(auth): bound bootstrap handoff scopes (#72919)
* fix(auth): bound bootstrap handoff scopes Co-authored-by: zsx <git@zsxsoft.com> * fix(auth): log stripped bootstrap scopes * docs: add changelog entry for bootstrap handoff scope bounds --------- Co-authored-by: zsx <git@zsxsoft.com> Co-authored-by: Devin Robison <drobison@nvidia.com>
1 parent 60c2a90 commit b8372a7

7 files changed

Lines changed: 324 additions & 20 deletions

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -230,6 +230,7 @@ Docs: https://docs.openclaw.ai
230230
- Gateway/startup: skip inherited workspace startup memory for sandboxed spawned sessions without real-workspace write access, so `/new` no longer preloads host workspace memory into isolated child runs. (#73611) Thanks @drobison00.
231231
- Agents/tool policy: validate caller group IDs against session or spawned context before applying group-scoped tool policies or persisting gateway group metadata, so forged group IDs cannot unlock more permissive tools. (#73720) Thanks @mmaps.
232232
- Commands: keep channel-prefixed owner allowlist entries scoped to matching providers so webchat command contexts cannot inherit external channel owners. Thanks @zsxsoft.
233+
- Auth/device pairing: bound bootstrap handoff token issuance, redemption, and approved pairing baselines to the documented per-role scope allowlist, so bootstrap approvals cannot persistently grant `operator.admin`, `operator.pairing`, or `node.exec` scopes. Thanks @eleqtrizit.
233234

234235
## 2026.4.27
235236

src/infra/device-bootstrap.test.ts

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import fs from "node:fs/promises";
22
import path from "node:path";
33
import { afterEach, describe, expect, it, vi } from "vitest";
4+
import { resetLogger, setLoggerOverride } from "../logging.js";
45
import { createTrackedTempDirs } from "../test-utils/tracked-temp-dirs.js";
56
import {
67
clearDeviceBootstrapTokens,
@@ -40,6 +41,8 @@ async function verifyBootstrapToken(
4041

4142
afterEach(async () => {
4243
vi.useRealTimers();
44+
resetLogger();
45+
setLoggerOverride(null);
4346
await tempDirs.cleanup();
4447
});
4548

@@ -329,6 +332,95 @@ describe("device bootstrap tokens", () => {
329332
).resolves.toEqual({ ok: true });
330333
});
331334

335+
it("bounds explicitly issued bootstrap profiles to handoff scopes", async () => {
336+
const baseDir = await createTempDir();
337+
const issued = await issueDeviceBootstrapToken({
338+
baseDir,
339+
profile: {
340+
roles: ["node", "operator"],
341+
scopes: [
342+
"node.exec",
343+
"operator.admin",
344+
"operator.approvals",
345+
"operator.pairing",
346+
"operator.read",
347+
"operator.talk.secrets",
348+
"operator.write",
349+
],
350+
},
351+
});
352+
353+
await expect(getDeviceBootstrapTokenProfile({ baseDir, token: issued.token })).resolves.toEqual(
354+
{
355+
roles: ["node", "operator"],
356+
scopes: ["operator.approvals", "operator.read", "operator.talk.secrets", "operator.write"],
357+
},
358+
);
359+
await expect(
360+
verifyBootstrapToken(baseDir, issued.token, {
361+
role: "operator",
362+
scopes: ["operator.admin"],
363+
}),
364+
).resolves.toEqual({ ok: false, reason: "bootstrap_token_invalid" });
365+
});
366+
367+
it("logs when issued bootstrap profiles strip overbroad scopes", async () => {
368+
const baseDir = await createTempDir();
369+
const logPath = path.join(baseDir, "bootstrap.log");
370+
setLoggerOverride({ level: "warn", consoleLevel: "silent", file: logPath });
371+
372+
await issueDeviceBootstrapToken({
373+
baseDir,
374+
profile: {
375+
roles: ["node", "operator"],
376+
scopes: ["node.exec", "operator.admin", "operator.read"],
377+
},
378+
});
379+
380+
const content = await fs.readFile(logPath, "utf8");
381+
expect(content).toContain("bootstrap_token_scopes_stripped");
382+
expect(content).toContain("node.exec");
383+
expect(content).toContain("operator.admin");
384+
expect(content).toContain("operator.read");
385+
});
386+
387+
it("bounds redeemed bootstrap profiles to handoff scopes", async () => {
388+
const baseDir = await createTempDir();
389+
const issued = await issueDeviceBootstrapToken({
390+
baseDir,
391+
profile: {
392+
roles: ["operator"],
393+
scopes: ["operator.approvals", "operator.read", "operator.talk.secrets", "operator.write"],
394+
},
395+
});
396+
397+
await expect(
398+
redeemDeviceBootstrapTokenProfile({
399+
baseDir,
400+
token: issued.token,
401+
role: "operator",
402+
scopes: [
403+
"operator.admin",
404+
"operator.approvals",
405+
"operator.pairing",
406+
"operator.read",
407+
"operator.talk.secrets",
408+
"operator.write",
409+
],
410+
}),
411+
).resolves.toEqual({ recorded: true, fullyRedeemed: true });
412+
413+
const raw = await fs.readFile(resolveBootstrapPath(baseDir), "utf8");
414+
const parsed = JSON.parse(raw) as Record<
415+
string,
416+
{ redeemedProfile?: { roles?: string[]; scopes?: string[] } }
417+
>;
418+
expect(parsed[issued.token]?.redeemedProfile).toEqual({
419+
roles: ["operator"],
420+
scopes: ["operator.approvals", "operator.read", "operator.talk.secrets", "operator.write"],
421+
});
422+
});
423+
332424
it("accepts trimmed bootstrap tokens and binds them", async () => {
333425
const baseDir = await createTempDir();
334426
const issued = await issueDeviceBootstrapToken({ baseDir });

src/infra/device-bootstrap.ts

Lines changed: 54 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
11
import path from "node:path";
2+
import { createSubsystemLogger } from "../logging/subsystem.js";
23
import {
4+
normalizeDeviceBootstrapHandoffProfile,
35
normalizeDeviceBootstrapProfile,
46
PAIRING_SETUP_BOOTSTRAP_PROFILE,
7+
resolveBootstrapProfileScopesForRole,
58
type DeviceBootstrapProfile,
69
type DeviceBootstrapProfileInput,
710
} from "../shared/device-bootstrap-profile.js";
@@ -34,11 +37,29 @@ export type DeviceBootstrapTokenRecord = {
3437
type DeviceBootstrapStateFile = Record<string, DeviceBootstrapTokenRecord>;
3538

3639
const withLock = createAsyncLock();
40+
const log = createSubsystemLogger("device-bootstrap");
3741

3842
function resolveBootstrapPath(baseDir?: string): string {
3943
return path.join(resolvePairingPaths(baseDir, "devices").dir, "bootstrap.json");
4044
}
4145

46+
function resolveIssuedBootstrapProfileInput(params: {
47+
profile?: DeviceBootstrapProfileInput;
48+
roles?: readonly string[];
49+
scopes?: readonly string[];
50+
}): DeviceBootstrapProfileInput | undefined {
51+
if (params.profile) {
52+
return params.profile;
53+
}
54+
if (params.roles || params.scopes) {
55+
return {
56+
roles: params.roles,
57+
scopes: params.scopes,
58+
};
59+
}
60+
return undefined;
61+
}
62+
4263
function resolvePersistedBootstrapProfile(
4364
record: Partial<DeviceBootstrapTokenRecord>,
4465
): DeviceBootstrapProfile {
@@ -56,18 +77,39 @@ function resolveIssuedBootstrapProfile(params: {
5677
roles?: readonly string[];
5778
scopes?: readonly string[];
5879
}): DeviceBootstrapProfile {
59-
if (params.profile) {
60-
return normalizeDeviceBootstrapProfile(params.profile);
61-
}
62-
if (params.roles || params.scopes) {
63-
return normalizeDeviceBootstrapProfile({
64-
roles: params.roles,
65-
scopes: params.scopes,
66-
});
80+
const input = resolveIssuedBootstrapProfileInput(params);
81+
if (input) {
82+
return normalizeDeviceBootstrapHandoffProfile(input);
6783
}
6884
return PAIRING_SETUP_BOOTSTRAP_PROFILE;
6985
}
7086

87+
function warnIfIssuedBootstrapScopesWereStripped(params: {
88+
input: DeviceBootstrapProfileInput | undefined;
89+
profile: DeviceBootstrapProfile;
90+
}): void {
91+
if (!params.input) {
92+
return;
93+
}
94+
const requestedProfile = normalizeDeviceBootstrapProfile(params.input);
95+
const requestedScopes = requestedProfile.scopes;
96+
if (requestedScopes.length === 0) {
97+
return;
98+
}
99+
const retainedScopeSet = new Set(params.profile.scopes);
100+
const strippedScopes = requestedScopes.filter((scope) => !retainedScopeSet.has(scope));
101+
if (strippedScopes.length === 0) {
102+
return;
103+
}
104+
log.warn("bootstrap_token_scopes_stripped", {
105+
roles: requestedProfile.roles,
106+
requestedScopes,
107+
retainedScopes: params.profile.scopes,
108+
strippedScopes,
109+
consoleMessage: "bootstrap token scopes stripped to bootstrap handoff allowlist",
110+
});
111+
}
112+
71113
function bootstrapProfileAllowsRequest(params: {
72114
allowedProfile: DeviceBootstrapProfile;
73115
requestedRole: string;
@@ -83,13 +125,6 @@ function bootstrapProfileAllowsRequest(params: {
83125
);
84126
}
85127

86-
function resolveBootstrapProfileScopes(role: string, scopes: readonly string[]): string[] {
87-
if (role === "operator") {
88-
return scopes.filter((scope) => scope.startsWith("operator."));
89-
}
90-
return scopes.filter((scope) => !scope.startsWith("operator."));
91-
}
92-
93128
function bootstrapProfileSatisfiesProfile(params: {
94129
actualProfile: DeviceBootstrapProfile;
95130
requiredProfile: DeviceBootstrapProfile;
@@ -98,7 +133,7 @@ function bootstrapProfileSatisfiesProfile(params: {
98133
if (!params.actualProfile.roles.includes(requiredRole)) {
99134
return false;
100135
}
101-
const requiredScopes = resolveBootstrapProfileScopes(
136+
const requiredScopes = resolveBootstrapProfileScopesForRole(
102137
requiredRole,
103138
params.requiredProfile.scopes,
104139
);
@@ -175,7 +210,9 @@ export async function issueDeviceBootstrapToken(
175210
const state = await loadState(params.baseDir);
176211
const token = generatePairingToken();
177212
const issuedAtMs = Date.now();
213+
const profileInput = resolveIssuedBootstrapProfileInput(params);
178214
const profile = resolveIssuedBootstrapProfile(params);
215+
warnIfIssuedBootstrapScopesWereStripped({ input: profileInput, profile });
179216
state[token] = {
180217
token,
181218
ts: issuedAtMs,
@@ -276,7 +313,7 @@ export async function redeemDeviceBootstrapTokenProfile(params: {
276313
roles: [...resolvePersistedRedeemedProfile(record).roles, params.role],
277314
scopes: [
278315
...resolvePersistedRedeemedProfile(record).scopes,
279-
...resolveBootstrapProfileScopes(params.role, params.scopes),
316+
...resolveBootstrapProfileScopesForRole(params.role, params.scopes),
280317
],
281318
});
282319
state[tokenKey] = {

src/infra/device-pairing.test.ts

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -948,6 +948,118 @@ describe("device pairing tokens", () => {
948948
expect(paired?.tokens?.node?.scopes).toEqual([]);
949949
});
950950

951+
test("bootstrap pairing bounds approved baseline to handoff scopes", async () => {
952+
const baseDir = await makeDevicePairingDir();
953+
const request = await requestDevicePairing(
954+
{
955+
deviceId: "bootstrap-device-bounded-baseline",
956+
publicKey: "bootstrap-public-key-bounded-baseline",
957+
role: "node",
958+
roles: ["node", "operator"],
959+
scopes: [],
960+
silent: true,
961+
},
962+
baseDir,
963+
);
964+
965+
await expect(
966+
approveBootstrapDevicePairing(
967+
request.request.requestId,
968+
{
969+
roles: ["node", "operator"],
970+
scopes: [
971+
"node.exec",
972+
"operator.admin",
973+
"operator.approvals",
974+
"operator.pairing",
975+
"operator.read",
976+
"operator.talk.secrets",
977+
"operator.write",
978+
],
979+
},
980+
baseDir,
981+
),
982+
).resolves.toEqual(expect.objectContaining({ status: "approved" }));
983+
984+
const paired = await getPairedDevice("bootstrap-device-bounded-baseline", baseDir);
985+
expect(paired?.approvedScopes).toEqual([
986+
"operator.approvals",
987+
"operator.read",
988+
"operator.talk.secrets",
989+
"operator.write",
990+
]);
991+
expect(paired?.tokens?.operator?.scopes).toEqual([
992+
"operator.approvals",
993+
"operator.read",
994+
"operator.talk.secrets",
995+
"operator.write",
996+
]);
997+
expect(paired?.tokens?.node?.scopes).toEqual([]);
998+
await expect(
999+
ensureDeviceToken({
1000+
deviceId: "bootstrap-device-bounded-baseline",
1001+
role: "operator",
1002+
scopes: ["operator.admin"],
1003+
baseDir,
1004+
}),
1005+
).resolves.toBeNull();
1006+
});
1007+
1008+
test("bootstrap pairing sanitizes merged legacy baseline scopes", async () => {
1009+
const baseDir = await makeDevicePairingDir();
1010+
const first = await requestDevicePairing(
1011+
{
1012+
deviceId: "bootstrap-device-legacy-baseline",
1013+
publicKey: "bootstrap-public-key-legacy-baseline",
1014+
role: "node",
1015+
roles: ["node", "operator"],
1016+
scopes: [],
1017+
silent: true,
1018+
},
1019+
baseDir,
1020+
);
1021+
1022+
await approveBootstrapDevicePairing(
1023+
first.request.requestId,
1024+
PAIRING_SETUP_BOOTSTRAP_PROFILE,
1025+
baseDir,
1026+
);
1027+
await mutatePairedDevice(baseDir, "bootstrap-device-legacy-baseline", (device) => {
1028+
device.approvedScopes = ["operator.admin"];
1029+
device.scopes = ["operator.admin"];
1030+
});
1031+
1032+
const repair = await requestDevicePairing(
1033+
{
1034+
deviceId: "bootstrap-device-legacy-baseline",
1035+
publicKey: "bootstrap-public-key-legacy-baseline-rotated",
1036+
role: "node",
1037+
roles: ["node", "operator"],
1038+
scopes: [],
1039+
silent: true,
1040+
},
1041+
baseDir,
1042+
);
1043+
await expect(
1044+
approveBootstrapDevicePairing(
1045+
repair.request.requestId,
1046+
PAIRING_SETUP_BOOTSTRAP_PROFILE,
1047+
baseDir,
1048+
),
1049+
).resolves.toEqual(expect.objectContaining({ status: "approved" }));
1050+
1051+
const paired = await getPairedDevice("bootstrap-device-legacy-baseline", baseDir);
1052+
expect(paired?.approvedScopes).toEqual(PAIRING_SETUP_BOOTSTRAP_PROFILE.scopes);
1053+
await expect(
1054+
ensureDeviceToken({
1055+
deviceId: "bootstrap-device-legacy-baseline",
1056+
role: "operator",
1057+
scopes: ["operator.admin"],
1058+
baseDir,
1059+
}),
1060+
).resolves.toBeNull();
1061+
});
1062+
9511063
test("verifies token and rejects mismatches", async () => {
9521064
const { baseDir, token } = await setupOperatorToken(["operator.read"]);
9531065

0 commit comments

Comments
 (0)