mirror of
https://github.com/openclaw/openclaw.git
synced 2026-03-14 11:30:41 +00:00
Gateway/Pairing: guard rotate scope escalation openclaw#20703 thanks @coygeek
This commit is contained in:
@@ -13,6 +13,7 @@ Docs: https://docs.openclaw.ai
|
||||
|
||||
### Fixes
|
||||
|
||||
- Gateway/Pairing: prevent device-token rotate scope escalation by enforcing an approved-scope baseline, preserving approved scopes across metadata updates, and rejecting rotate requests that exceed approved role scope implications. (#20703) thanks @coygeek.
|
||||
- Gateway/Security: require secure context and paired-device checks for Control UI auth even when `gateway.controlUi.allowInsecureAuth` is set, and align audit messaging with the hardened behavior. (#20684) thanks @coygeek.
|
||||
- macOS/Build: default release packaging to `BUNDLE_ID=ai.openclaw.mac` in `scripts/package-mac-dist.sh`, so Sparkle feed URL is retained and auto-update no longer fails with an empty appcast feed. (#19750) thanks @loganprit.
|
||||
|
||||
|
||||
@@ -24,7 +24,7 @@ import type { GatewayRequestHandlers } from "./types.js";
|
||||
function redactPairedDevice(
|
||||
device: { tokens?: Record<string, DeviceAuthToken> } & Record<string, unknown>,
|
||||
) {
|
||||
const { tokens, ...rest } = device;
|
||||
const { tokens, approvedScopes: _approvedScopes, ...rest } = device;
|
||||
return {
|
||||
...rest,
|
||||
tokens: summarizeDeviceTokens(tokens),
|
||||
|
||||
@@ -97,7 +97,7 @@ describe("device pairing tokens", () => {
|
||||
expect(Buffer.from(token, "base64url")).toHaveLength(32);
|
||||
});
|
||||
|
||||
test("preserves existing token scopes when rotating without scopes", async () => {
|
||||
test("allows down-scoping from admin and preserves approved scope baseline", async () => {
|
||||
const baseDir = await mkdtemp(join(tmpdir(), "openclaw-device-pairing-"));
|
||||
await setupPairedOperatorDevice(baseDir, ["operator.admin"]);
|
||||
|
||||
@@ -109,7 +109,8 @@ describe("device pairing tokens", () => {
|
||||
});
|
||||
let paired = await getPairedDevice("device-1", baseDir);
|
||||
expect(paired?.tokens?.operator?.scopes).toEqual(["operator.read"]);
|
||||
expect(paired?.scopes).toEqual(["operator.read"]);
|
||||
expect(paired?.scopes).toEqual(["operator.admin"]);
|
||||
expect(paired?.approvedScopes).toEqual(["operator.admin"]);
|
||||
|
||||
await rotateDeviceToken({
|
||||
deviceId: "device-1",
|
||||
@@ -120,6 +121,26 @@ describe("device pairing tokens", () => {
|
||||
expect(paired?.tokens?.operator?.scopes).toEqual(["operator.read"]);
|
||||
});
|
||||
|
||||
test("rejects scope escalation when rotating a token and leaves state unchanged", async () => {
|
||||
const baseDir = await mkdtemp(join(tmpdir(), "openclaw-device-pairing-"));
|
||||
await setupPairedOperatorDevice(baseDir, ["operator.read"]);
|
||||
const before = await getPairedDevice("device-1", baseDir);
|
||||
|
||||
const rotated = await rotateDeviceToken({
|
||||
deviceId: "device-1",
|
||||
role: "operator",
|
||||
scopes: ["operator.admin"],
|
||||
baseDir,
|
||||
});
|
||||
expect(rotated).toBeNull();
|
||||
|
||||
const after = await getPairedDevice("device-1", baseDir);
|
||||
expect(after?.tokens?.operator?.token).toEqual(before?.tokens?.operator?.token);
|
||||
expect(after?.tokens?.operator?.scopes).toEqual(["operator.read"]);
|
||||
expect(after?.scopes).toEqual(["operator.read"]);
|
||||
expect(after?.approvedScopes).toEqual(["operator.read"]);
|
||||
});
|
||||
|
||||
test("verifies token and rejects mismatches", async () => {
|
||||
const baseDir = await mkdtemp(join(tmpdir(), "openclaw-device-pairing-"));
|
||||
await setupPairedOperatorDevice(baseDir, ["operator.read"]);
|
||||
|
||||
@@ -56,6 +56,7 @@ export type PairedDevice = {
|
||||
role?: string;
|
||||
roles?: string[];
|
||||
scopes?: string[];
|
||||
approvedScopes?: string[];
|
||||
remoteIp?: string;
|
||||
tokens?: Record<string, DeviceAuthToken>;
|
||||
createdAtMs: number;
|
||||
@@ -176,6 +177,33 @@ function mergePendingDevicePairingRequest(
|
||||
};
|
||||
}
|
||||
|
||||
const DEVICE_SCOPE_IMPLICATIONS: Readonly<Record<string, readonly string[]>> = {
|
||||
"operator.admin": ["operator.read", "operator.write", "operator.approvals", "operator.pairing"],
|
||||
"operator.write": ["operator.read"],
|
||||
};
|
||||
|
||||
function expandScopeImplications(scopes: string[]): string[] {
|
||||
const expanded = new Set(scopes);
|
||||
const queue = [...scopes];
|
||||
while (queue.length > 0) {
|
||||
const scope = queue.pop();
|
||||
if (!scope) {
|
||||
continue;
|
||||
}
|
||||
for (const impliedScope of DEVICE_SCOPE_IMPLICATIONS[scope] ?? []) {
|
||||
if (!expanded.has(impliedScope)) {
|
||||
expanded.add(impliedScope);
|
||||
queue.push(impliedScope);
|
||||
}
|
||||
}
|
||||
}
|
||||
return [...expanded];
|
||||
}
|
||||
|
||||
function scopesAllowWithImplications(requested: string[], allowed: string[]): boolean {
|
||||
return scopesAllow(expandScopeImplications(requested), expandScopeImplications(allowed));
|
||||
}
|
||||
|
||||
function newToken() {
|
||||
return generatePairingToken();
|
||||
}
|
||||
@@ -286,7 +314,10 @@ export async function approveDevicePairing(
|
||||
const now = Date.now();
|
||||
const existing = state.pairedByDeviceId[pending.deviceId];
|
||||
const roles = mergeRoles(existing?.roles, existing?.role, pending.roles, pending.role);
|
||||
const scopes = mergeScopes(existing?.scopes, pending.scopes);
|
||||
const approvedScopes = mergeScopes(
|
||||
existing?.approvedScopes ?? existing?.scopes,
|
||||
pending.scopes,
|
||||
);
|
||||
const tokens = existing?.tokens ? { ...existing.tokens } : {};
|
||||
const roleForToken = normalizeRole(pending.role);
|
||||
if (roleForToken) {
|
||||
@@ -312,7 +343,8 @@ export async function approveDevicePairing(
|
||||
clientMode: pending.clientMode,
|
||||
role: pending.role,
|
||||
roles,
|
||||
scopes,
|
||||
scopes: approvedScopes,
|
||||
approvedScopes,
|
||||
remoteIp: pending.remoteIp,
|
||||
tokens,
|
||||
createdAtMs: existing?.createdAtMs ?? now,
|
||||
@@ -359,7 +391,9 @@ export async function removePairedDevice(
|
||||
|
||||
export async function updatePairedDeviceMetadata(
|
||||
deviceId: string,
|
||||
patch: Partial<Omit<PairedDevice, "deviceId" | "createdAtMs" | "approvedAtMs">>,
|
||||
patch: Partial<
|
||||
Omit<PairedDevice, "deviceId" | "createdAtMs" | "approvedAtMs" | "approvedScopes">
|
||||
>,
|
||||
baseDir?: string,
|
||||
): Promise<void> {
|
||||
return await withLock(async () => {
|
||||
@@ -376,6 +410,7 @@ export async function updatePairedDeviceMetadata(
|
||||
deviceId: existing.deviceId,
|
||||
createdAtMs: existing.createdAtMs,
|
||||
approvedAtMs: existing.approvedAtMs,
|
||||
approvedScopes: existing.approvedScopes,
|
||||
role: patch.role ?? existing.role,
|
||||
roles,
|
||||
scopes,
|
||||
@@ -525,6 +560,12 @@ export async function rotateDeviceToken(params: {
|
||||
const requestedScopes = normalizeDeviceAuthScopes(
|
||||
params.scopes ?? existing?.scopes ?? device.scopes,
|
||||
);
|
||||
const approvedScopes = normalizeDeviceAuthScopes(
|
||||
device.approvedScopes ?? device.scopes ?? existing?.scopes,
|
||||
);
|
||||
if (!scopesAllowWithImplications(requestedScopes, approvedScopes)) {
|
||||
return null;
|
||||
}
|
||||
const now = Date.now();
|
||||
const next = buildDeviceAuthToken({
|
||||
role,
|
||||
@@ -535,9 +576,6 @@ export async function rotateDeviceToken(params: {
|
||||
});
|
||||
tokens[role] = next;
|
||||
device.tokens = tokens;
|
||||
if (params.scopes !== undefined) {
|
||||
device.scopes = requestedScopes;
|
||||
}
|
||||
state.pairedByDeviceId[device.deviceId] = device;
|
||||
await persistState(state, params.baseDir);
|
||||
return next;
|
||||
|
||||
Reference in New Issue
Block a user