Improve transport safety and log redaction

This commit is contained in:
nav
2026-04-08 23:03:54 +00:00
parent 075fcb7974
commit 4f20ec3fd7
4 changed files with 207 additions and 8 deletions

41
plugin/core/logging.ts Normal file
View File

@@ -0,0 +1,41 @@
const DEFAULT_VISIBLE_EDGE = 4;
export type RedactableValue = string | null | undefined;
export function redactSecret(value: RedactableValue, visibleEdge: number = DEFAULT_VISIBLE_EDGE): string {
return redactValue(value, { visibleEdge, label: "secret" });
}
export function redactPairingCode(value: RedactableValue, visibleEdge: number = DEFAULT_VISIBLE_EDGE): string {
return redactValue(value, { visibleEdge, label: "pairingCode" });
}
export function redactKey(value: RedactableValue, visibleEdge: number = DEFAULT_VISIBLE_EDGE): string {
return redactValue(value, { visibleEdge, label: "key" });
}
export function redactValue(
value: RedactableValue,
options: { visibleEdge?: number; label?: string } = {}
): string {
const visibleEdge = options.visibleEdge ?? DEFAULT_VISIBLE_EDGE;
const label = options.label ?? "value";
if (!value) {
return `<redacted:${label}:empty>`;
}
if (value.length <= visibleEdge * 2) {
return `<redacted:${label}:${value.length}>`;
}
return `${value.slice(0, visibleEdge)}${value.slice(-visibleEdge)} <redacted:${label}:${value.length}>`;
}
export function safeErrorMessage(error: unknown): string {
if (error instanceof Error) {
return error.message;
}
return String(error);
}

View File

@@ -17,12 +17,15 @@ import {
buildPairRequest,
buildPairSuccess,
buildRePairRequired,
CodecError,
decodeBuiltin,
encodeBuiltin,
encodeRuleMessage,
extractAuthRequestSigningInput,
isBuiltinMessage,
isTimestampFresh,
isValidAuthNonce,
parseRuleMessage,
type AuthRequestPayload,
type HeartbeatPayload
} from "../../../Yonexus.Protocol/src/index.js";
@@ -43,6 +46,7 @@ import {
createDiscordNotificationService,
type DiscordNotificationService
} from "../notifications/discord.js";
import { safeErrorMessage } from "./logging.js";
export interface YonexusServerRuntimeOptions {
config: YonexusServerConfig;
@@ -143,10 +147,27 @@ export class YonexusServerRuntime {
async handleMessage(connection: ClientConnection, raw: string): Promise<void> {
if (!isBuiltinMessage(raw)) {
// Handle rule message - rewrite and dispatch
await this.handleRuleMessage(connection, raw);
return;
}
const envelope = decodeBuiltin(raw);
let envelope: BuiltinEnvelope;
try {
envelope = decodeBuiltin(raw);
} catch (error) {
const message = error instanceof CodecError ? error.message : "Invalid builtin message";
this.options.transport.sendToConnection(
connection,
encodeBuiltin(
buildError(
{ code: "MALFORMED_MESSAGE", message },
{ timestamp: this.now() }
)
)
);
return;
}
if (envelope.type === "hello") {
await this.handleHello(connection, envelope as BuiltinEnvelope<"hello", HelloPayload>);
@@ -174,7 +195,21 @@ export class YonexusServerRuntime {
connection,
envelope as BuiltinEnvelope<"heartbeat", HeartbeatPayload>
);
return;
}
this.options.transport.sendToConnection(
connection,
encodeBuiltin(
buildError(
{
code: "MALFORMED_MESSAGE",
message: `Unsupported builtin type: ${String(envelope.type)}`
},
{ requestId: envelope.requestId, timestamp: this.now() }
)
)
);
}
private async handleHello(
@@ -803,6 +838,123 @@ export class YonexusServerRuntime {
private async persist(): Promise<void> {
await this.options.store.save(this.registry.clients.values());
}
/**
* Send a rule message to a specific client.
*
* @param identifier - The target client identifier
* @param message - The complete rule message with identifier and content
* @returns True if message was sent, false if client not connected/authenticated
*/
sendMessageToClient(identifier: string, message: string): boolean {
const session = this.registry.sessions.get(identifier);
if (!session || !session.isAuthenticated) {
return false;
}
// Validate the message is a properly formatted rule message
try {
// Quick check: must not be a builtin message and must have :: delimiter
if (message.startsWith("builtin::")) {
return false;
}
const delimiterIndex = message.indexOf("::");
if (delimiterIndex === -1) {
return false;
}
parseRuleMessage(message);
} catch {
return false;
}
return this.options.transport.send(identifier, message);
}
/**
* Send a rule message to a specific client using separate rule identifier and content.
*
* @param identifier - The target client identifier
* @param ruleIdentifier - The rule identifier
* @param content - The message content
* @returns True if message was sent, false if client not connected/authenticated or invalid format
*/
sendRuleMessageToClient(identifier: string, ruleIdentifier: string, content: string): boolean {
const session = this.registry.sessions.get(identifier);
if (!session || !session.isAuthenticated) {
return false;
}
try {
const encoded = encodeRuleMessage(ruleIdentifier, content);
return this.options.transport.send(identifier, encoded);
} catch {
return false;
}
}
/**
* Handle incoming rule message from a client.
* Rewrites the message to include sender identifier before dispatch.
*
* @param connection - The client connection
* @param raw - The raw rule message
*/
private async handleRuleMessage(connection: ClientConnection, raw: string): Promise<void> {
// Get sender identifier from connection or session
let senderIdentifier = connection.identifier;
if (!senderIdentifier) {
// Try to find identifier from WebSocket
for (const [id, session] of this.registry.sessions.entries()) {
if (session.socket === connection.ws) {
senderIdentifier = id;
break;
}
}
}
if (!senderIdentifier) {
// Cannot determine sender - close connection
connection.ws.close(1008, "Cannot identify sender");
return;
}
const session = this.registry.sessions.get(senderIdentifier);
if (!session || !session.isAuthenticated) {
// Only accept rule messages from authenticated clients
connection.ws.close(1008, "Not authenticated");
return;
}
try {
const parsed = parseRuleMessage(raw);
const rewritten = `${parsed.ruleIdentifier}::${senderIdentifier}::${parsed.content}`;
// TODO: Dispatch to registered rules via rule registry
// For now, just log the rewritten message
// this.ruleRegistry.dispatch(rewritten);
// Update last activity
session.lastActivityAt = this.now();
// Future: dispatch to rule registry
// eslint-disable-next-line @typescript-eslint/no-unused-vars
void rewritten;
} catch (error) {
// Malformed rule message
this.options.transport.sendToConnection(
connection,
encodeBuiltin(
buildError(
{
code: "MALFORMED_MESSAGE",
message: safeErrorMessage(error) || "Invalid rule message format"
},
{ timestamp: this.now() }
)
)
);
}
}
}
export function createYonexusServerRuntime(

View File

@@ -1,6 +1,6 @@
import { WebSocketServer, WebSocket, RawData } from "ws";
import type { YonexusServerConfig } from "./config.js";
import type { ClientRecord } from "./persistence.js";
import { safeErrorMessage } from "./logging.js";
export interface ClientConnection {
readonly identifier: string | null;
@@ -221,7 +221,7 @@ export class YonexusServerTransport implements ServerTransport {
ws.on("error", (error: Error) => {
// Log error but let close handler clean up
console.error("WebSocket error:", error.message);
console.error("[Yonexus.Server] WebSocket error:", safeErrorMessage(error));
});
if (this.options.onConnect) {

View File

@@ -5,6 +5,7 @@
*/
import type { PairingRequest } from "../services/pairing.js";
import { redactPairingCode } from "../core/logging.js";
export interface DiscordNotificationService {
/**
@@ -36,11 +37,16 @@ export function createDiscordNotificationService(
): DiscordNotificationService {
return {
async sendPairingNotification(request: PairingRequest): Promise<boolean> {
const message = formatPairingMessage(request);
const redactedCode = redactPairingCode(request.pairingCode);
// Log to console (visible in OpenClaw logs)
console.log("[Yonexus.Server] Pairing notification (Discord DM stub):");
console.log(message);
console.log("[Yonexus.Server] Pairing notification (Discord DM stub):", {
identifier: request.identifier,
pairingCode: redactedCode,
expiresAt: request.expiresAt,
ttlSeconds: request.ttlSeconds,
adminUserId: config.adminUserId
});
// TODO: Replace with actual Discord bot integration
// Example with discord.js:
@@ -59,7 +65,7 @@ export function createDiscordNotificationService(
/**
* Format a pairing request as a Discord DM message.
*/
function formatPairingMessage(request: PairingRequest): string {
export function formatPairingMessage(request: PairingRequest): string {
const expiresDate = new Date(request.expiresAt * 1000);
const expiresStr = expiresDate.toISOString();
@@ -89,7 +95,7 @@ export function createMockNotificationService(
async sendPairingNotification(request: PairingRequest): Promise<boolean> {
console.log("[Yonexus.Server] Mock pairing notification:");
console.log(` Identifier: ${request.identifier}`);
console.log(` Pairing Code: ${request.pairingCode}`);
console.log(` Pairing Code: ${redactPairingCode(request.pairingCode)}`);
console.log(` Success: ${shouldSucceed}`);
return shouldSucceed;
}