mirror of
https://github.com/moltbot/moltbot.git
synced 2026-05-18 20:19:47 +00:00
fix(auth): prevent bootstrap pairing scope changes [AI] (#80976)
* fix: prevent bootstrap pairing scope changes before redemption * docs: add changelog entry for PR merge
This commit is contained in:
committed by
GitHub
parent
abd2ba1fe0
commit
2d00bedc1e
@@ -6,6 +6,7 @@ Docs: https://docs.openclaw.ai
|
||||
|
||||
### Fixes
|
||||
|
||||
- fix(auth): prevent bootstrap pairing scope changes [AI]. (#80976) Thanks @pgondhi987.
|
||||
- Validate Control UI loopback retry endpoints [AI]. (#80900) Thanks @pgondhi987.
|
||||
- Harden exported markdown link rendering [AI]. (#80902) Thanks @pgondhi987.
|
||||
- fix(gateway): honor minimal discovery mode for wide-area DNS-SD [AI]. (#80903) Thanks @pgondhi987.
|
||||
|
||||
@@ -97,6 +97,54 @@ describe("device bootstrap tokens", () => {
|
||||
expect(parsed[issued.token]?.publicKey).toBe("public-key-123");
|
||||
});
|
||||
|
||||
it("rejects changing the requested profile while a bound use is pending", async () => {
|
||||
const baseDir = await createTempDir();
|
||||
const issued = await issueDeviceBootstrapToken({
|
||||
baseDir,
|
||||
profile: {
|
||||
roles: ["operator"],
|
||||
scopes: ["operator.approvals", "operator.read", "operator.write"],
|
||||
},
|
||||
});
|
||||
|
||||
await expect(
|
||||
verifyBootstrapToken(baseDir, issued.token, {
|
||||
role: "operator",
|
||||
scopes: ["operator.read"],
|
||||
}),
|
||||
).resolves.toEqual({ ok: true });
|
||||
await expect(
|
||||
verifyBootstrapToken(baseDir, issued.token, {
|
||||
role: "operator",
|
||||
scopes: ["operator.write", "operator.approvals"],
|
||||
}),
|
||||
).resolves.toEqual({ ok: false, reason: "bootstrap_token_invalid" });
|
||||
await expect(
|
||||
verifyBootstrapToken(baseDir, issued.token, {
|
||||
role: "operator",
|
||||
scopes: ["operator.read"],
|
||||
}),
|
||||
).resolves.toEqual({ ok: true });
|
||||
|
||||
await expect(
|
||||
redeemDeviceBootstrapTokenProfile({
|
||||
baseDir,
|
||||
token: issued.token,
|
||||
role: "operator",
|
||||
scopes: ["operator.read"],
|
||||
}),
|
||||
).resolves.toEqual({
|
||||
recorded: true,
|
||||
fullyRedeemed: false,
|
||||
});
|
||||
await expect(
|
||||
verifyBootstrapToken(baseDir, issued.token, {
|
||||
role: "operator",
|
||||
scopes: ["operator.write", "operator.approvals"],
|
||||
}),
|
||||
).resolves.toEqual({ ok: true });
|
||||
});
|
||||
|
||||
it("loads the issued bootstrap profile for a valid token", async () => {
|
||||
const baseDir = await createTempDir();
|
||||
const issued = await issueDeviceBootstrapToken({ baseDir });
|
||||
|
||||
@@ -23,6 +23,7 @@ export type DeviceBootstrapTokenRecord = {
|
||||
publicKey?: string;
|
||||
profile?: DeviceBootstrapProfile;
|
||||
redeemedProfile?: DeviceBootstrapProfile;
|
||||
pendingProfile?: DeviceBootstrapProfile;
|
||||
roles?: string[];
|
||||
scopes?: string[];
|
||||
issuedAtMs: number;
|
||||
@@ -67,6 +68,35 @@ function resolvePersistedRedeemedProfile(
|
||||
return normalizeDeviceBootstrapProfile(record.redeemedProfile);
|
||||
}
|
||||
|
||||
function resolvePersistedPendingProfile(
|
||||
record: Partial<DeviceBootstrapTokenRecord>,
|
||||
): DeviceBootstrapProfile | null {
|
||||
return record.pendingProfile ? normalizeDeviceBootstrapProfile(record.pendingProfile) : null;
|
||||
}
|
||||
|
||||
function resolveRequestedBootstrapProfile(params: {
|
||||
role: string;
|
||||
scopes: readonly string[];
|
||||
}): DeviceBootstrapProfile {
|
||||
return normalizeDeviceBootstrapProfile({
|
||||
roles: [params.role],
|
||||
scopes: resolveBootstrapProfileScopesForRole(params.role, params.scopes),
|
||||
});
|
||||
}
|
||||
|
||||
function sameBootstrapProfile(
|
||||
left: DeviceBootstrapProfile,
|
||||
right: DeviceBootstrapProfile,
|
||||
): boolean {
|
||||
if (left.roles.length !== right.roles.length || left.scopes.length !== right.scopes.length) {
|
||||
return false;
|
||||
}
|
||||
return (
|
||||
left.roles.every((role, index) => role === right.roles[index]) &&
|
||||
left.scopes.every((scope, index) => scope === right.scopes[index])
|
||||
);
|
||||
}
|
||||
|
||||
function resolveIssuedBootstrapProfile(params: {
|
||||
profile?: DeviceBootstrapProfileInput;
|
||||
roles?: readonly string[];
|
||||
@@ -173,10 +203,12 @@ async function loadState(baseDir?: string): Promise<DeviceBootstrapStateFile> {
|
||||
typeof record.token === "string" && record.token.trim().length > 0 ? record.token : tokenKey;
|
||||
const issuedAtMs = typeof record.issuedAtMs === "number" ? record.issuedAtMs : 0;
|
||||
const profile = resolvePersistedBootstrapProfile(record);
|
||||
const pendingProfile = resolvePersistedPendingProfile(record);
|
||||
state[tokenKey] = {
|
||||
token,
|
||||
profile,
|
||||
redeemedProfile: resolvePersistedRedeemedProfile(record),
|
||||
...(pendingProfile ? { pendingProfile } : {}),
|
||||
deviceId: typeof record.deviceId === "string" ? record.deviceId : undefined,
|
||||
publicKey: typeof record.publicKey === "string" ? record.publicKey : undefined,
|
||||
issuedAtMs,
|
||||
@@ -304,6 +336,7 @@ export async function redeemDeviceBootstrapTokenProfile(params: {
|
||||
}
|
||||
const [tokenKey, record] = found;
|
||||
const issuedProfile = resolvePersistedBootstrapProfile(record);
|
||||
const pendingProfile = resolvePersistedPendingProfile(record);
|
||||
const redeemedProfile = normalizeDeviceBootstrapProfile({
|
||||
roles: [...resolvePersistedRedeemedProfile(record).roles, params.role],
|
||||
scopes: [
|
||||
@@ -311,11 +344,25 @@ export async function redeemDeviceBootstrapTokenProfile(params: {
|
||||
...resolveBootstrapProfileScopesForRole(params.role, params.scopes),
|
||||
],
|
||||
});
|
||||
state[tokenKey] = {
|
||||
const nextPendingProfile =
|
||||
pendingProfile &&
|
||||
!bootstrapProfileSatisfiesProfile({
|
||||
actualProfile: redeemedProfile,
|
||||
requiredProfile: pendingProfile,
|
||||
})
|
||||
? pendingProfile
|
||||
: undefined;
|
||||
const nextRecord: DeviceBootstrapTokenRecord = {
|
||||
...record,
|
||||
profile: issuedProfile,
|
||||
redeemedProfile,
|
||||
};
|
||||
if (nextPendingProfile) {
|
||||
nextRecord.pendingProfile = nextPendingProfile;
|
||||
} else {
|
||||
delete nextRecord.pendingProfile;
|
||||
}
|
||||
state[tokenKey] = nextRecord;
|
||||
await persistState(state, params.baseDir);
|
||||
return {
|
||||
recorded: true,
|
||||
@@ -368,6 +415,10 @@ export async function verifyDeviceBootstrapToken(params: {
|
||||
) {
|
||||
return { ok: false, reason: "bootstrap_token_invalid" };
|
||||
}
|
||||
const requestedProfile = resolveRequestedBootstrapProfile({
|
||||
role,
|
||||
scopes: params.scopes,
|
||||
});
|
||||
|
||||
const boundDeviceId = record.deviceId?.trim();
|
||||
const boundPublicKey =
|
||||
@@ -378,9 +429,14 @@ export async function verifyDeviceBootstrapToken(params: {
|
||||
if (boundDeviceId !== deviceId || boundPublicKey !== publicKey) {
|
||||
return { ok: false, reason: "bootstrap_token_invalid" };
|
||||
}
|
||||
const pendingProfile = resolvePersistedPendingProfile(record);
|
||||
if (pendingProfile && !sameBootstrapProfile(pendingProfile, requestedProfile)) {
|
||||
return { ok: false, reason: "bootstrap_token_invalid" };
|
||||
}
|
||||
state[tokenKey] = {
|
||||
...record,
|
||||
profile: allowedProfile,
|
||||
pendingProfile: pendingProfile ?? requestedProfile,
|
||||
deviceId,
|
||||
publicKey,
|
||||
lastUsedAtMs: Date.now(),
|
||||
@@ -392,6 +448,7 @@ export async function verifyDeviceBootstrapToken(params: {
|
||||
state[tokenKey] = {
|
||||
...record,
|
||||
profile: allowedProfile,
|
||||
pendingProfile: requestedProfile,
|
||||
deviceId,
|
||||
publicKey,
|
||||
lastUsedAtMs: Date.now(),
|
||||
|
||||
@@ -590,7 +590,7 @@ describe("device pairing tokens", () => {
|
||||
const issued = await issueDeviceBootstrapToken({
|
||||
baseDir,
|
||||
roles: ["operator"],
|
||||
scopes: ["operator.read"],
|
||||
scopes: ["operator.approvals", "operator.read", "operator.write"],
|
||||
});
|
||||
|
||||
await expect(
|
||||
@@ -620,11 +620,15 @@ describe("device pairing tokens", () => {
|
||||
deviceId: "device-1",
|
||||
publicKey: "public-key-1",
|
||||
role: "operator",
|
||||
scopes: ["operator.admin"],
|
||||
scopes: ["operator.write", "operator.approvals"],
|
||||
baseDir,
|
||||
}),
|
||||
).resolves.toEqual({ ok: false, reason: "bootstrap_token_invalid" });
|
||||
|
||||
const pending = await listDevicePairing(baseDir);
|
||||
expect(pending.pending).toHaveLength(1);
|
||||
expect(pending.pending[0]?.scopes).toEqual(["operator.read"]);
|
||||
|
||||
await approveDevicePairing(
|
||||
first.request.requestId,
|
||||
{ callerScopes: ["operator.read"] },
|
||||
|
||||
Reference in New Issue
Block a user