feat(gateway): emit security events for auth handshakes

This commit is contained in:
Vincent Koc
2026-06-16 17:54:12 +08:00
parent d6774e46e0
commit 299d31c56e
2 changed files with 233 additions and 15 deletions
@@ -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;