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"],
|
||||
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",
|
||||
remoteAddr: "127.0.0.1",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
import {
|
||||
approveDevicePairing,
|
||||
getPendingDevicePairing,
|
||||
getPairedDevice,
|
||||
listDevicePairing,
|
||||
removePairedDevice,
|
||||
|
|
@ -94,31 +93,20 @@ export const deviceHandlers: GatewayRequestHandlers = {
|
|||
return;
|
||||
}
|
||||
const { requestId } = params as { requestId: string };
|
||||
const pending = await getPendingDevicePairing(requestId);
|
||||
if (!pending) {
|
||||
const callerScopes = Array.isArray(client?.connect?.scopes) ? client.connect.scopes : [];
|
||||
const approved = await approveDevicePairing(requestId, { callerScopes });
|
||||
if (!approved) {
|
||||
respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, "unknown requestId"));
|
||||
return;
|
||||
}
|
||||
const callerScopes = Array.isArray(client?.connect?.scopes) ? client.connect.scopes : [];
|
||||
const requestedScopes = normalizeDeviceAuthScopes(pending.scopes);
|
||||
const missingScope = resolveMissingRequestedScope({
|
||||
role: pending.role,
|
||||
requestedScopes,
|
||||
callerScopes,
|
||||
});
|
||||
if (missingScope) {
|
||||
if (approved.status === "forbidden") {
|
||||
respond(
|
||||
false,
|
||||
undefined,
|
||||
errorShape(ErrorCodes.INVALID_REQUEST, `missing scope: ${missingScope}`),
|
||||
errorShape(ErrorCodes.INVALID_REQUEST, `missing scope: ${approved.missingScope}`),
|
||||
);
|
||||
return;
|
||||
}
|
||||
const approved = await approveDevicePairing(requestId);
|
||||
if (!approved) {
|
||||
respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, "unknown requestId"));
|
||||
return;
|
||||
}
|
||||
context.logGateway.info(
|
||||
`device pairing approved device=${approved.device.deviceId} role=${approved.device.role ?? "unknown"}`,
|
||||
);
|
||||
|
|
|
|||
|
|
@ -71,6 +71,11 @@ export type DevicePairingList = {
|
|||
paired: PairedDevice[];
|
||||
};
|
||||
|
||||
export type ApproveDevicePairingResult =
|
||||
| { status: "approved"; requestId: string; device: PairedDevice }
|
||||
| { status: "forbidden"; missingScope: string }
|
||||
| null;
|
||||
|
||||
type DevicePairingStateFile = {
|
||||
pendingById: Record<string, DevicePairingPendingRequest>;
|
||||
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> {
|
||||
const state = await loadState(baseDir);
|
||||
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(
|
||||
requestId: string,
|
||||
options?: { callerScopes?: readonly string[] },
|
||||
baseDir?: string,
|
||||
): Promise<{ requestId: string; device: PairedDevice } | null> {
|
||||
): Promise<ApproveDevicePairingResult> {
|
||||
return await withLock(async () => {
|
||||
const state = await loadState(baseDir);
|
||||
const pending = state.pendingById[requestId];
|
||||
if (!pending) {
|
||||
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 existing = state.pairedByDeviceId[pending.deviceId];
|
||||
const roles = mergeRoles(existing?.roles, existing?.role, pending.roles, pending.role);
|
||||
|
|
@ -372,7 +407,7 @@ export async function approveDevicePairing(
|
|||
delete state.pendingById[requestId];
|
||||
state.pairedByDeviceId[device.deviceId] = device;
|
||||
await persistState(state, baseDir);
|
||||
return { requestId, device };
|
||||
return { status: "approved", requestId, device };
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue