From 299d31c56ef1967df8009807f22e7acbb49dabfa Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Tue, 16 Jun 2026 17:54:12 +0800 Subject: [PATCH] feat(gateway): emit security events for auth handshakes --- ...essage-handler.post-connect-health.test.ts | 139 ++++++++++++++++-- .../server/ws-connection/message-handler.ts | 109 ++++++++++++++ 2 files changed, 233 insertions(+), 15 deletions(-) diff --git a/src/gateway/server/ws-connection/message-handler.post-connect-health.test.ts b/src/gateway/server/ws-connection/message-handler.post-connect-health.test.ts index ef1159105e95..760623e9f520 100644 --- a/src/gateway/server/ws-connection/message-handler.post-connect-health.test.ts +++ b/src/gateway/server/ws-connection/message-handler.post-connect-health.test.ts @@ -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(); } +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(async () => createHealthSummary(), diff --git a/src/gateway/server/ws-connection/message-handler.ts b/src/gateway/server/ws-connection/message-handler.ts index 3e31e979b65b..ea8fc5c91c95 100644 --- a/src/gateway/server/ws-connection/message-handler.ts +++ b/src/gateway/server/ws-connection/message-handler.ts @@ -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;