mirror of
https://github.com/openclaw/openclaw.git
synced 2026-02-19 18:39:20 -05: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,
|
||||
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);
|
||||
expect(paired?.scopes).toContain("operator.admin");
|
||||
|
||||
ws2.close();
|
||||
ws3.close();
|
||||
await server.close();
|
||||
restoreGatewayToken(prevToken);
|
||||
});
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import type { IncomingMessage } from "node:http";
|
||||
import os from "node:os";
|
||||
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 {
|
||||
deriveDeviceIdFromPublicKey,
|
||||
@@ -20,7 +24,6 @@ import { recordRemoteNodeInfo, refreshRemoteNodeBins } from "../../../infra/skil
|
||||
import { upsertPresence } from "../../../infra/system-presence.js";
|
||||
import { loadVoiceWakeConfig } from "../../../infra/voicewake.js";
|
||||
import { rawDataToString } from "../../../infra/ws.js";
|
||||
import type { createSubsystemLogger } from "../../../logging/subsystem.js";
|
||||
import { isGatewayCliClient, isWebchatClient } from "../../../utils/message-channel.js";
|
||||
import { resolveRuntimeServiceVersion } from "../../../version.js";
|
||||
import {
|
||||
@@ -28,7 +31,6 @@ import {
|
||||
AUTH_RATE_LIMIT_SCOPE_SHARED_SECRET,
|
||||
type AuthRateLimiter,
|
||||
} from "../../auth-rate-limit.js";
|
||||
import type { GatewayAuthResult, ResolvedGatewayAuth } from "../../auth.js";
|
||||
import { authorizeGatewayConnect, isLocalDirectRequest } from "../../auth.js";
|
||||
import { buildDeviceAuthPayload } from "../../device-auth.js";
|
||||
import { isLoopbackAddress, isTrustedProxyAddress, resolveGatewayClientIp } from "../../net.js";
|
||||
@@ -48,7 +50,6 @@ import {
|
||||
} from "../../protocol/index.js";
|
||||
import { MAX_BUFFERED_BYTES, MAX_PAYLOAD_BYTES, TICK_INTERVAL_MS } from "../../server-constants.js";
|
||||
import { handleGatewayRequest } from "../../server-methods.js";
|
||||
import type { GatewayRequestContext, GatewayRequestHandlers } from "../../server-methods/types.js";
|
||||
import { formatError } from "../../server-utils.js";
|
||||
import { formatForLog, logWs } from "../../ws-log.js";
|
||||
import { truncateCloseReason } from "../close-reason.js";
|
||||
@@ -59,7 +60,6 @@ import {
|
||||
incrementPresenceVersion,
|
||||
refreshGatewayHealthSnapshot,
|
||||
} from "../health-state.js";
|
||||
import type { GatewayWsClient } from "../ws-types.js";
|
||||
import { formatGatewayAuthFailureMessage, type AuthProvidedKind } from "./auth-messages.js";
|
||||
|
||||
type SubsystemLogger = ReturnType<typeof createSubsystemLogger>;
|
||||
@@ -616,7 +616,34 @@ export function attachGatewayWsMessageHandler(params: {
|
||||
|
||||
const skipPairing = allowControlUiBypass && sharedAuthOk;
|
||||
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({
|
||||
deviceId: device.id,
|
||||
publicKey: devicePublicKey,
|
||||
@@ -627,7 +654,7 @@ export function attachGatewayWsMessageHandler(params: {
|
||||
role,
|
||||
scopes,
|
||||
remoteIp: reportedClientIp,
|
||||
silent: isLocalClient,
|
||||
silent: isLocalClient && reason === "not-paired",
|
||||
});
|
||||
const context = buildRequestContext();
|
||||
if (pairing.request.silent === true) {
|
||||
@@ -679,16 +706,21 @@ export function attachGatewayWsMessageHandler(params: {
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
const allowedRoles = new Set(
|
||||
Array.isArray(paired.roles) ? paired.roles : paired.role ? [paired.role] : [],
|
||||
);
|
||||
const pairedRoles = Array.isArray(paired.roles)
|
||||
? paired.roles
|
||||
: paired.role
|
||||
? [paired.role]
|
||||
: [];
|
||||
const allowedRoles = new Set(pairedRoles);
|
||||
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) {
|
||||
return;
|
||||
}
|
||||
} 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) {
|
||||
return;
|
||||
}
|
||||
@@ -697,7 +729,8 @@ export function attachGatewayWsMessageHandler(params: {
|
||||
const pairedScopes = Array.isArray(paired.scopes) ? paired.scopes : [];
|
||||
if (scopes.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) {
|
||||
return;
|
||||
}
|
||||
@@ -705,7 +738,8 @@ export function attachGatewayWsMessageHandler(params: {
|
||||
const allowedScopes = new Set(pairedScopes);
|
||||
const missingScope = scopes.find((scope) => !allowedScopes.has(scope));
|
||||
if (missingScope) {
|
||||
const ok = await requirePairing("scope-upgrade", paired);
|
||||
logUpgradeAudit("scope-upgrade", pairedRoles, pairedScopes);
|
||||
const ok = await requirePairing("scope-upgrade");
|
||||
if (!ok) {
|
||||
return;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user