mirror of https://github.com/openclaw/openclaw.git
Gateway: make device approval scope checks atomic
This commit is contained in:
parent
84dab1c310
commit
5e2f07597e
|
|
@ -223,6 +223,13 @@ describe("resolveClientIp", () => {
|
||||||
trustedProxies: ["127.0.0.1", "::1"],
|
trustedProxies: ["127.0.0.1", "::1"],
|
||||||
expected: undefined,
|
expected: undefined,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: "fails closed when all non-loopback X-Forwarded-For hops are trusted proxies",
|
||||||
|
remoteAddr: "10.0.0.50",
|
||||||
|
forwardedFor: "10.0.0.2, 10.0.0.1",
|
||||||
|
trustedProxies: ["10.0.0.0/8"],
|
||||||
|
expected: undefined,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: "fails closed when trusted proxy omits forwarding headers",
|
name: "fails closed when trusted proxy omits forwarding headers",
|
||||||
remoteAddr: "127.0.0.1",
|
remoteAddr: "127.0.0.1",
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,5 @@
|
||||||
import {
|
import {
|
||||||
approveDevicePairing,
|
approveDevicePairing,
|
||||||
getPendingDevicePairing,
|
|
||||||
getPairedDevice,
|
getPairedDevice,
|
||||||
listDevicePairing,
|
listDevicePairing,
|
||||||
removePairedDevice,
|
removePairedDevice,
|
||||||
|
|
@ -94,31 +93,20 @@ export const deviceHandlers: GatewayRequestHandlers = {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const { requestId } = params as { requestId: string };
|
const { requestId } = params as { requestId: string };
|
||||||
const pending = await getPendingDevicePairing(requestId);
|
const callerScopes = Array.isArray(client?.connect?.scopes) ? client.connect.scopes : [];
|
||||||
if (!pending) {
|
const approved = await approveDevicePairing(requestId, { callerScopes });
|
||||||
|
if (!approved) {
|
||||||
respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, "unknown requestId"));
|
respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, "unknown requestId"));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const callerScopes = Array.isArray(client?.connect?.scopes) ? client.connect.scopes : [];
|
if (approved.status === "forbidden") {
|
||||||
const requestedScopes = normalizeDeviceAuthScopes(pending.scopes);
|
|
||||||
const missingScope = resolveMissingRequestedScope({
|
|
||||||
role: pending.role,
|
|
||||||
requestedScopes,
|
|
||||||
callerScopes,
|
|
||||||
});
|
|
||||||
if (missingScope) {
|
|
||||||
respond(
|
respond(
|
||||||
false,
|
false,
|
||||||
undefined,
|
undefined,
|
||||||
errorShape(ErrorCodes.INVALID_REQUEST, `missing scope: ${missingScope}`),
|
errorShape(ErrorCodes.INVALID_REQUEST, `missing scope: ${approved.missingScope}`),
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const approved = await approveDevicePairing(requestId);
|
|
||||||
if (!approved) {
|
|
||||||
respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, "unknown requestId"));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
context.logGateway.info(
|
context.logGateway.info(
|
||||||
`device pairing approved device=${approved.device.deviceId} role=${approved.device.role ?? "unknown"}`,
|
`device pairing approved device=${approved.device.deviceId} role=${approved.device.role ?? "unknown"}`,
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -71,6 +71,11 @@ export type DevicePairingList = {
|
||||||
paired: PairedDevice[];
|
paired: PairedDevice[];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type ApproveDevicePairingResult =
|
||||||
|
| { status: "approved"; requestId: string; device: PairedDevice }
|
||||||
|
| { status: "forbidden"; missingScope: string }
|
||||||
|
| null;
|
||||||
|
|
||||||
type DevicePairingStateFile = {
|
type DevicePairingStateFile = {
|
||||||
pendingById: Record<string, DevicePairingPendingRequest>;
|
pendingById: Record<string, DevicePairingPendingRequest>;
|
||||||
pairedByDeviceId: Record<string, PairedDevice>;
|
pairedByDeviceId: Record<string, PairedDevice>;
|
||||||
|
|
@ -237,6 +242,25 @@ function scopesWithinApprovedDeviceBaseline(params: {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function resolveMissingRequestedScope(params: {
|
||||||
|
role: string;
|
||||||
|
requestedScopes: readonly string[];
|
||||||
|
callerScopes: readonly string[];
|
||||||
|
}): string | null {
|
||||||
|
for (const scope of params.requestedScopes) {
|
||||||
|
if (
|
||||||
|
!roleScopesAllow({
|
||||||
|
role: params.role,
|
||||||
|
requestedScopes: [scope],
|
||||||
|
allowedScopes: params.callerScopes,
|
||||||
|
})
|
||||||
|
) {
|
||||||
|
return scope;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
export async function listDevicePairing(baseDir?: string): Promise<DevicePairingList> {
|
export async function listDevicePairing(baseDir?: string): Promise<DevicePairingList> {
|
||||||
const state = await loadState(baseDir);
|
const state = await loadState(baseDir);
|
||||||
const pending = Object.values(state.pendingById).toSorted((a, b) => b.ts - a.ts);
|
const pending = Object.values(state.pendingById).toSorted((a, b) => b.ts - a.ts);
|
||||||
|
|
@ -312,14 +336,25 @@ export async function requestDevicePairing(
|
||||||
|
|
||||||
export async function approveDevicePairing(
|
export async function approveDevicePairing(
|
||||||
requestId: string,
|
requestId: string,
|
||||||
|
options?: { callerScopes?: readonly string[] },
|
||||||
baseDir?: string,
|
baseDir?: string,
|
||||||
): Promise<{ requestId: string; device: PairedDevice } | null> {
|
): Promise<ApproveDevicePairingResult> {
|
||||||
return await withLock(async () => {
|
return await withLock(async () => {
|
||||||
const state = await loadState(baseDir);
|
const state = await loadState(baseDir);
|
||||||
const pending = state.pendingById[requestId];
|
const pending = state.pendingById[requestId];
|
||||||
if (!pending) {
|
if (!pending) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
if (pending.role && options?.callerScopes) {
|
||||||
|
const missingScope = resolveMissingRequestedScope({
|
||||||
|
role: pending.role,
|
||||||
|
requestedScopes: normalizeDeviceAuthScopes(pending.scopes),
|
||||||
|
callerScopes: options.callerScopes,
|
||||||
|
});
|
||||||
|
if (missingScope) {
|
||||||
|
return { status: "forbidden", missingScope };
|
||||||
|
}
|
||||||
|
}
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
const existing = state.pairedByDeviceId[pending.deviceId];
|
const existing = state.pairedByDeviceId[pending.deviceId];
|
||||||
const roles = mergeRoles(existing?.roles, existing?.role, pending.roles, pending.role);
|
const roles = mergeRoles(existing?.roles, existing?.role, pending.roles, pending.role);
|
||||||
|
|
@ -372,7 +407,7 @@ export async function approveDevicePairing(
|
||||||
delete state.pendingById[requestId];
|
delete state.pendingById[requestId];
|
||||||
state.pairedByDeviceId[device.deviceId] = device;
|
state.pairedByDeviceId[device.deviceId] = device;
|
||||||
await persistState(state, baseDir);
|
await persistState(state, baseDir);
|
||||||
return { requestId, device };
|
return { status: "approved", requestId, device };
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue