mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-09 19:54:32 +00:00
fix(security): require explicit approval for device access upgrades
This commit is contained in:
@@ -925,11 +925,25 @@ describe("gateway server auth/connect", () => {
|
|||||||
client,
|
client,
|
||||||
device: buildDevice(["operator.admin"]),
|
device: buildDevice(["operator.admin"]),
|
||||||
});
|
});
|
||||||
expect(res.ok).toBe(true);
|
expect(res.ok).toBe(false);
|
||||||
|
expect(res.error?.message ?? "").toContain("pairing required");
|
||||||
|
|
||||||
|
await approvePendingPairingIfNeeded();
|
||||||
|
ws2.close();
|
||||||
|
|
||||||
|
const ws3 = new WebSocket(`ws://127.0.0.1:${port}`);
|
||||||
|
await new Promise<void>((resolve) => ws3.once("open", resolve));
|
||||||
|
const approved = await connectReq(ws3, {
|
||||||
|
token: "secret",
|
||||||
|
scopes: ["operator.admin"],
|
||||||
|
client,
|
||||||
|
device: buildDevice(["operator.admin"]),
|
||||||
|
});
|
||||||
|
expect(approved.ok).toBe(true);
|
||||||
paired = await getPairedDevice(identity.deviceId);
|
paired = await getPairedDevice(identity.deviceId);
|
||||||
expect(paired?.scopes).toContain("operator.admin");
|
expect(paired?.scopes).toContain("operator.admin");
|
||||||
|
|
||||||
ws2.close();
|
ws3.close();
|
||||||
await server.close();
|
await server.close();
|
||||||
restoreGatewayToken(prevToken);
|
restoreGatewayToken(prevToken);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
import type { IncomingMessage } from "node:http";
|
import type { IncomingMessage } from "node:http";
|
||||||
import os from "node:os";
|
|
||||||
import type { WebSocket } from "ws";
|
import type { WebSocket } from "ws";
|
||||||
|
import os from "node:os";
|
||||||
|
import type { createSubsystemLogger } from "../../../logging/subsystem.js";
|
||||||
|
import type { GatewayAuthResult, ResolvedGatewayAuth } from "../../auth.js";
|
||||||
|
import type { GatewayRequestContext, GatewayRequestHandlers } from "../../server-methods/types.js";
|
||||||
|
import type { GatewayWsClient } from "../ws-types.js";
|
||||||
import { loadConfig } from "../../../config/config.js";
|
import { loadConfig } from "../../../config/config.js";
|
||||||
import {
|
import {
|
||||||
deriveDeviceIdFromPublicKey,
|
deriveDeviceIdFromPublicKey,
|
||||||
@@ -20,7 +24,6 @@ import { recordRemoteNodeInfo, refreshRemoteNodeBins } from "../../../infra/skil
|
|||||||
import { upsertPresence } from "../../../infra/system-presence.js";
|
import { upsertPresence } from "../../../infra/system-presence.js";
|
||||||
import { loadVoiceWakeConfig } from "../../../infra/voicewake.js";
|
import { loadVoiceWakeConfig } from "../../../infra/voicewake.js";
|
||||||
import { rawDataToString } from "../../../infra/ws.js";
|
import { rawDataToString } from "../../../infra/ws.js";
|
||||||
import type { createSubsystemLogger } from "../../../logging/subsystem.js";
|
|
||||||
import { isGatewayCliClient, isWebchatClient } from "../../../utils/message-channel.js";
|
import { isGatewayCliClient, isWebchatClient } from "../../../utils/message-channel.js";
|
||||||
import { resolveRuntimeServiceVersion } from "../../../version.js";
|
import { resolveRuntimeServiceVersion } from "../../../version.js";
|
||||||
import {
|
import {
|
||||||
@@ -28,7 +31,6 @@ import {
|
|||||||
AUTH_RATE_LIMIT_SCOPE_SHARED_SECRET,
|
AUTH_RATE_LIMIT_SCOPE_SHARED_SECRET,
|
||||||
type AuthRateLimiter,
|
type AuthRateLimiter,
|
||||||
} from "../../auth-rate-limit.js";
|
} from "../../auth-rate-limit.js";
|
||||||
import type { GatewayAuthResult, ResolvedGatewayAuth } from "../../auth.js";
|
|
||||||
import { authorizeGatewayConnect, isLocalDirectRequest } from "../../auth.js";
|
import { authorizeGatewayConnect, isLocalDirectRequest } from "../../auth.js";
|
||||||
import { buildDeviceAuthPayload } from "../../device-auth.js";
|
import { buildDeviceAuthPayload } from "../../device-auth.js";
|
||||||
import { isLoopbackAddress, isTrustedProxyAddress, resolveGatewayClientIp } from "../../net.js";
|
import { isLoopbackAddress, isTrustedProxyAddress, resolveGatewayClientIp } from "../../net.js";
|
||||||
@@ -48,7 +50,6 @@ import {
|
|||||||
} from "../../protocol/index.js";
|
} from "../../protocol/index.js";
|
||||||
import { MAX_BUFFERED_BYTES, MAX_PAYLOAD_BYTES, TICK_INTERVAL_MS } from "../../server-constants.js";
|
import { MAX_BUFFERED_BYTES, MAX_PAYLOAD_BYTES, TICK_INTERVAL_MS } from "../../server-constants.js";
|
||||||
import { handleGatewayRequest } from "../../server-methods.js";
|
import { handleGatewayRequest } from "../../server-methods.js";
|
||||||
import type { GatewayRequestContext, GatewayRequestHandlers } from "../../server-methods/types.js";
|
|
||||||
import { formatError } from "../../server-utils.js";
|
import { formatError } from "../../server-utils.js";
|
||||||
import { formatForLog, logWs } from "../../ws-log.js";
|
import { formatForLog, logWs } from "../../ws-log.js";
|
||||||
import { truncateCloseReason } from "../close-reason.js";
|
import { truncateCloseReason } from "../close-reason.js";
|
||||||
@@ -59,7 +60,6 @@ import {
|
|||||||
incrementPresenceVersion,
|
incrementPresenceVersion,
|
||||||
refreshGatewayHealthSnapshot,
|
refreshGatewayHealthSnapshot,
|
||||||
} from "../health-state.js";
|
} from "../health-state.js";
|
||||||
import type { GatewayWsClient } from "../ws-types.js";
|
|
||||||
import { formatGatewayAuthFailureMessage, type AuthProvidedKind } from "./auth-messages.js";
|
import { formatGatewayAuthFailureMessage, type AuthProvidedKind } from "./auth-messages.js";
|
||||||
|
|
||||||
type SubsystemLogger = ReturnType<typeof createSubsystemLogger>;
|
type SubsystemLogger = ReturnType<typeof createSubsystemLogger>;
|
||||||
@@ -616,7 +616,34 @@ export function attachGatewayWsMessageHandler(params: {
|
|||||||
|
|
||||||
const skipPairing = allowControlUiBypass && sharedAuthOk;
|
const skipPairing = allowControlUiBypass && sharedAuthOk;
|
||||||
if (device && devicePublicKey && !skipPairing) {
|
if (device && devicePublicKey && !skipPairing) {
|
||||||
const requirePairing = async (reason: string, _paired?: { deviceId: string }) => {
|
const formatAuditList = (items: string[] | undefined): string => {
|
||||||
|
if (!items || items.length === 0) {
|
||||||
|
return "<none>";
|
||||||
|
}
|
||||||
|
const out = new Set<string>();
|
||||||
|
for (const item of items) {
|
||||||
|
const trimmed = item.trim();
|
||||||
|
if (trimmed) {
|
||||||
|
out.add(trimmed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (out.size === 0) {
|
||||||
|
return "<none>";
|
||||||
|
}
|
||||||
|
return [...out].toSorted().join(",");
|
||||||
|
};
|
||||||
|
const logUpgradeAudit = (
|
||||||
|
reason: "role-upgrade" | "scope-upgrade",
|
||||||
|
currentRoles: string[] | undefined,
|
||||||
|
currentScopes: string[] | undefined,
|
||||||
|
) => {
|
||||||
|
logGateway.warn(
|
||||||
|
`security audit: device access upgrade requested reason=${reason} device=${device.id} ip=${reportedClientIp ?? "unknown-ip"} auth=${authMethod} roleFrom=${formatAuditList(currentRoles)} roleTo=${role} scopesFrom=${formatAuditList(currentScopes)} scopesTo=${formatAuditList(scopes)} client=${connectParams.client.id} conn=${connId}`,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
const requirePairing = async (
|
||||||
|
reason: "not-paired" | "role-upgrade" | "scope-upgrade",
|
||||||
|
) => {
|
||||||
const pairing = await requestDevicePairing({
|
const pairing = await requestDevicePairing({
|
||||||
deviceId: device.id,
|
deviceId: device.id,
|
||||||
publicKey: devicePublicKey,
|
publicKey: devicePublicKey,
|
||||||
@@ -627,7 +654,7 @@ export function attachGatewayWsMessageHandler(params: {
|
|||||||
role,
|
role,
|
||||||
scopes,
|
scopes,
|
||||||
remoteIp: reportedClientIp,
|
remoteIp: reportedClientIp,
|
||||||
silent: isLocalClient,
|
silent: isLocalClient && reason === "not-paired",
|
||||||
});
|
});
|
||||||
const context = buildRequestContext();
|
const context = buildRequestContext();
|
||||||
if (pairing.request.silent === true) {
|
if (pairing.request.silent === true) {
|
||||||
@@ -679,16 +706,21 @@ export function attachGatewayWsMessageHandler(params: {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
const allowedRoles = new Set(
|
const pairedRoles = Array.isArray(paired.roles)
|
||||||
Array.isArray(paired.roles) ? paired.roles : paired.role ? [paired.role] : [],
|
? paired.roles
|
||||||
);
|
: paired.role
|
||||||
|
? [paired.role]
|
||||||
|
: [];
|
||||||
|
const allowedRoles = new Set(pairedRoles);
|
||||||
if (allowedRoles.size === 0) {
|
if (allowedRoles.size === 0) {
|
||||||
const ok = await requirePairing("role-upgrade", paired);
|
logUpgradeAudit("role-upgrade", pairedRoles, paired.scopes);
|
||||||
|
const ok = await requirePairing("role-upgrade");
|
||||||
if (!ok) {
|
if (!ok) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
} else if (!allowedRoles.has(role)) {
|
} else if (!allowedRoles.has(role)) {
|
||||||
const ok = await requirePairing("role-upgrade", paired);
|
logUpgradeAudit("role-upgrade", pairedRoles, paired.scopes);
|
||||||
|
const ok = await requirePairing("role-upgrade");
|
||||||
if (!ok) {
|
if (!ok) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -697,7 +729,8 @@ export function attachGatewayWsMessageHandler(params: {
|
|||||||
const pairedScopes = Array.isArray(paired.scopes) ? paired.scopes : [];
|
const pairedScopes = Array.isArray(paired.scopes) ? paired.scopes : [];
|
||||||
if (scopes.length > 0) {
|
if (scopes.length > 0) {
|
||||||
if (pairedScopes.length === 0) {
|
if (pairedScopes.length === 0) {
|
||||||
const ok = await requirePairing("scope-upgrade", paired);
|
logUpgradeAudit("scope-upgrade", pairedRoles, pairedScopes);
|
||||||
|
const ok = await requirePairing("scope-upgrade");
|
||||||
if (!ok) {
|
if (!ok) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -705,7 +738,8 @@ export function attachGatewayWsMessageHandler(params: {
|
|||||||
const allowedScopes = new Set(pairedScopes);
|
const allowedScopes = new Set(pairedScopes);
|
||||||
const missingScope = scopes.find((scope) => !allowedScopes.has(scope));
|
const missingScope = scopes.find((scope) => !allowedScopes.has(scope));
|
||||||
if (missingScope) {
|
if (missingScope) {
|
||||||
const ok = await requirePairing("scope-upgrade", paired);
|
logUpgradeAudit("scope-upgrade", pairedRoles, pairedScopes);
|
||||||
|
const ok = await requirePairing("scope-upgrade");
|
||||||
if (!ok) {
|
if (!ok) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user