mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-23 02:45:38 -06:00
feat(gateway): emit security events for auth handshakes
This commit is contained in:
@@ -4,6 +4,11 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { WebSocket } from "ws";
|
||||
import { PROTOCOL_VERSION } from "../../../../packages/gateway-protocol/src/index.js";
|
||||
import type { HealthSummary } from "../../../commands/health.types.js";
|
||||
import {
|
||||
onInternalDiagnosticEvent,
|
||||
resetDiagnosticEventsForTest,
|
||||
type DiagnosticSecurityEvent,
|
||||
} from "../../../infra/diagnostic-events.js";
|
||||
import type { ResolvedGatewayAuth } from "../../auth.js";
|
||||
import { getOperatorApprovalRuntimeToken } from "../../operator-approval-runtime-token.js";
|
||||
import { handleGatewayRequest } from "../../server-methods.js";
|
||||
@@ -153,6 +158,19 @@ function createSetCloseCauseMock() {
|
||||
return vi.fn<SetCloseCause>();
|
||||
}
|
||||
|
||||
function captureSecurityEvents(): {
|
||||
events: DiagnosticSecurityEvent[];
|
||||
stop: () => void;
|
||||
} {
|
||||
const events: DiagnosticSecurityEvent[] = [];
|
||||
const stop = onInternalDiagnosticEvent((event, metadata) => {
|
||||
if (metadata.trusted && event.type === "security.event") {
|
||||
events.push(event);
|
||||
}
|
||||
});
|
||||
return { events, stop };
|
||||
}
|
||||
|
||||
function attachGatewayHarness(options: {
|
||||
connId: string;
|
||||
connectNonce: string;
|
||||
@@ -263,6 +281,7 @@ function attachGatewayHarness(options: {
|
||||
|
||||
describe("attachGatewayWsMessageHandler post-connect health refresh", () => {
|
||||
beforeEach(() => {
|
||||
resetDiagnosticEventsForTest();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
@@ -403,25 +422,48 @@ describe("attachGatewayWsMessageHandler post-connect health refresh", () => {
|
||||
refreshHealthSnapshot,
|
||||
isClosed,
|
||||
});
|
||||
const captured = captureSecurityEvents();
|
||||
|
||||
harness.sendConnect("connect-1", {
|
||||
minProtocol: PROTOCOL_VERSION,
|
||||
maxProtocol: PROTOCOL_VERSION,
|
||||
client: {
|
||||
id: "openclaw-control-ui",
|
||||
version: "dev",
|
||||
platform: "test",
|
||||
mode: "ui",
|
||||
},
|
||||
role: "operator",
|
||||
caps: [],
|
||||
});
|
||||
try {
|
||||
harness.sendConnect("connect-1", {
|
||||
minProtocol: PROTOCOL_VERSION,
|
||||
maxProtocol: PROTOCOL_VERSION,
|
||||
client: {
|
||||
id: "openclaw-control-ui",
|
||||
version: "dev",
|
||||
platform: "test",
|
||||
mode: "ui",
|
||||
},
|
||||
role: "operator",
|
||||
caps: [],
|
||||
});
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(harness.socketSend).toHaveBeenCalled();
|
||||
});
|
||||
await vi.waitFor(() => {
|
||||
expect(harness.socketSend).toHaveBeenCalled();
|
||||
});
|
||||
} finally {
|
||||
captured.stop();
|
||||
}
|
||||
const hello = JSON.parse(harness.socketSend.mock.calls.at(0)?.[0] ?? "{}") as { ok?: boolean };
|
||||
expect(hello.ok).toBe(true);
|
||||
expect(captured.events).toHaveLength(1);
|
||||
expect(captured.events[0]).toMatchObject({
|
||||
action: "gateway.auth.succeeded",
|
||||
outcome: "success",
|
||||
severity: "low",
|
||||
actor: { kind: "operator", role: "operator" },
|
||||
target: { kind: "gateway", name: "websocket" },
|
||||
policy: { id: "gateway.websocket-auth", decision: "allow" },
|
||||
control: { id: "gateway.ws.connect", family: "auth" },
|
||||
attributes: {
|
||||
auth_mode: "none",
|
||||
auth_method: "none",
|
||||
auth_provided: "none",
|
||||
client_mode: "ui",
|
||||
has_device_identity: false,
|
||||
scope_count: 0,
|
||||
},
|
||||
});
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(refreshHealthSnapshot).toHaveBeenCalledWith({ probe: false });
|
||||
@@ -429,6 +471,73 @@ describe("attachGatewayWsMessageHandler post-connect health refresh", () => {
|
||||
resolveRefresh?.();
|
||||
});
|
||||
|
||||
it("emits a security event for rejected gateway auth", async () => {
|
||||
const close = createCloseMock();
|
||||
const harness = attachGatewayHarness({
|
||||
connId: "conn-auth-failed",
|
||||
connectNonce: "nonce-auth-failed",
|
||||
requestHost: "gateway.example.com:18789",
|
||||
remoteAddr: "203.0.113.50",
|
||||
resolvedAuth: {
|
||||
mode: "token",
|
||||
token: "gateway-token",
|
||||
allowTailscale: false,
|
||||
},
|
||||
close,
|
||||
});
|
||||
const captured = captureSecurityEvents();
|
||||
|
||||
try {
|
||||
harness.sendConnect("connect-auth-failed", {
|
||||
minProtocol: PROTOCOL_VERSION,
|
||||
maxProtocol: PROTOCOL_VERSION,
|
||||
client: {
|
||||
id: "gateway-client",
|
||||
version: "dev",
|
||||
platform: "test",
|
||||
mode: "backend",
|
||||
},
|
||||
role: "operator",
|
||||
scopes: ["operator.admin"],
|
||||
caps: [],
|
||||
auth: { token: "wrong-token" },
|
||||
});
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(close).toHaveBeenCalledWith(1008, expect.stringContaining("unauthorized"));
|
||||
});
|
||||
} finally {
|
||||
captured.stop();
|
||||
}
|
||||
|
||||
expect(captured.events).toHaveLength(1);
|
||||
expect(captured.events[0]).toMatchObject({
|
||||
action: "gateway.auth.failed",
|
||||
outcome: "denied",
|
||||
severity: "medium",
|
||||
reason: "token_mismatch",
|
||||
actor: { kind: "operator", role: "operator" },
|
||||
target: { kind: "gateway", name: "websocket" },
|
||||
policy: {
|
||||
id: "gateway.websocket-auth",
|
||||
decision: "deny",
|
||||
reason: "token_mismatch",
|
||||
},
|
||||
control: { id: "gateway.ws.connect", family: "auth" },
|
||||
attributes: {
|
||||
auth_mode: "token",
|
||||
auth_method: "token",
|
||||
auth_provided: "token",
|
||||
client_mode: "backend",
|
||||
has_device_identity: false,
|
||||
scope_count: 0,
|
||||
rate_limited: false,
|
||||
},
|
||||
});
|
||||
expect(JSON.stringify(captured.events)).not.toContain("wrong-token");
|
||||
expect(JSON.stringify(captured.events)).not.toContain("gateway-token");
|
||||
});
|
||||
|
||||
it("does not mark local backend self-pairing clients as approval runtimes", async () => {
|
||||
const refreshHealthSnapshot = vi.fn<GatewayRequestContext["refreshHealthSnapshot"]>(async () =>
|
||||
createHealthSummary(),
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
// WebSocket message handler validates frames, dispatches gateway RPCs, manages pairing, and reports responses.
|
||||
import { createHash } from "node:crypto";
|
||||
import fs from "node:fs";
|
||||
import type { IncomingMessage } from "node:http";
|
||||
import os from "node:os";
|
||||
@@ -66,6 +67,10 @@ import {
|
||||
updatePairedDeviceMetadata,
|
||||
verifyDeviceToken,
|
||||
} from "../../../infra/device-pairing.js";
|
||||
import {
|
||||
emitTrustedSecurityEvent,
|
||||
type DiagnosticSecurityEventInput,
|
||||
} from "../../../infra/diagnostic-events.js";
|
||||
import {
|
||||
createDiagnosticTraceContext,
|
||||
runWithDiagnosticTraceContext,
|
||||
@@ -188,6 +193,64 @@ class NodePairingRateLimitError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
function hashGatewaySecurityId(value: string | undefined): string | undefined {
|
||||
const normalized = value?.trim();
|
||||
if (!normalized) {
|
||||
return undefined;
|
||||
}
|
||||
return `sha256:${createHash("sha256").update(normalized).digest("hex").slice(0, 12)}`;
|
||||
}
|
||||
|
||||
function emitGatewayAuthSecurityEvent(params: {
|
||||
action: "gateway.auth.succeeded" | "gateway.auth.failed";
|
||||
outcome: DiagnosticSecurityEventInput["outcome"];
|
||||
severity: DiagnosticSecurityEventInput["severity"];
|
||||
authMode: string;
|
||||
authMethod?: string;
|
||||
authProvided?: string;
|
||||
role: string;
|
||||
scopes: readonly string[];
|
||||
clientMode?: string;
|
||||
deviceId?: string;
|
||||
reason?: string;
|
||||
rateLimited?: boolean;
|
||||
}) {
|
||||
emitTrustedSecurityEvent({
|
||||
category: "auth",
|
||||
action: params.action,
|
||||
outcome: params.outcome,
|
||||
severity: params.severity,
|
||||
actor: {
|
||||
kind: params.role === "node" ? "node" : "operator",
|
||||
...(params.deviceId ? { deviceIdHash: hashGatewaySecurityId(params.deviceId) } : {}),
|
||||
role: params.role,
|
||||
},
|
||||
target: {
|
||||
kind: "gateway",
|
||||
name: "websocket",
|
||||
},
|
||||
policy: {
|
||||
id: "gateway.websocket-auth",
|
||||
decision: params.outcome === "success" ? "allow" : "deny",
|
||||
...(params.reason ? { reason: params.reason } : {}),
|
||||
},
|
||||
control: {
|
||||
id: "gateway.ws.connect",
|
||||
family: "auth",
|
||||
},
|
||||
...(params.reason ? { reason: params.reason } : {}),
|
||||
attributes: {
|
||||
auth_mode: params.authMode,
|
||||
auth_method: params.authMethod ?? "unknown",
|
||||
auth_provided: params.authProvided ?? "unknown",
|
||||
client_mode: params.clientMode ?? "unknown",
|
||||
has_device_identity: Boolean(params.deviceId),
|
||||
scope_count: params.scopes.length,
|
||||
...(params.rateLimited !== undefined ? { rate_limited: params.rateLimited } : {}),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/** Match production release versions (YYYY.M.PATCH or YYYY.M.PATCH-beta.N). */
|
||||
const RELEASED_VERSION_RE = /^\d{4}\.\d+\.\d+/;
|
||||
|
||||
@@ -870,6 +933,20 @@ export function attachGatewayWsMessageHandler(params: GatewayWsMessageHandlerPar
|
||||
failedAuth,
|
||||
hasDeviceIdentity: Boolean(device),
|
||||
});
|
||||
emitGatewayAuthSecurityEvent({
|
||||
action: "gateway.auth.failed",
|
||||
outcome: "denied",
|
||||
severity: failedAuth.rateLimited ? "high" : "medium",
|
||||
authMode: resolvedAuth.mode,
|
||||
authMethod: failedAuth.method ?? authMethod,
|
||||
authProvided,
|
||||
role,
|
||||
scopes,
|
||||
clientMode: connectParams.client.mode,
|
||||
deviceId: device?.id,
|
||||
reason: failedAuth.reason ?? "unknown",
|
||||
rateLimited: failedAuth.rateLimited === true,
|
||||
});
|
||||
markHandshakeFailure("unauthorized", {
|
||||
authMode: resolvedAuth.mode,
|
||||
authProvided,
|
||||
@@ -1023,6 +1100,19 @@ export function attachGatewayWsMessageHandler(params: GatewayWsMessageHandlerPar
|
||||
}
|
||||
if (device) {
|
||||
const rejectDeviceAuthInvalid = (reason: string, message: string) => {
|
||||
emitGatewayAuthSecurityEvent({
|
||||
action: "gateway.auth.failed",
|
||||
outcome: "denied",
|
||||
severity: "medium",
|
||||
authMode: resolvedAuth.mode,
|
||||
authMethod,
|
||||
authProvided: "device-signature",
|
||||
role,
|
||||
scopes,
|
||||
clientMode: connectParams.client.mode,
|
||||
deviceId: device.id,
|
||||
reason,
|
||||
});
|
||||
setHandshakeState("failed");
|
||||
setCloseCause("device-auth-invalid", {
|
||||
reason,
|
||||
@@ -2025,6 +2115,25 @@ export function attachGatewayWsMessageHandler(params: GatewayWsMessageHandlerPar
|
||||
close();
|
||||
return;
|
||||
}
|
||||
emitGatewayAuthSecurityEvent({
|
||||
action: "gateway.auth.succeeded",
|
||||
outcome: "success",
|
||||
severity: "low",
|
||||
authMode: resolvedAuth.mode,
|
||||
authMethod,
|
||||
authProvided:
|
||||
authMethod === "device-token" || authMethod === "bootstrap-token"
|
||||
? authMethod
|
||||
: hasPasswordAuth
|
||||
? "password"
|
||||
: hasTokenAuth
|
||||
? "token"
|
||||
: authMethod,
|
||||
role,
|
||||
scopes: helloOkAuthScopes,
|
||||
clientMode: connectParams.client.mode,
|
||||
deviceId: device?.id,
|
||||
});
|
||||
if (pendingNodePairingCleanup) {
|
||||
const context = buildRequestContext();
|
||||
const cleanupClaim = pendingNodePairingCleanup;
|
||||
|
||||
Reference in New Issue
Block a user