From 3d015c4e8026a19282404bbd7f7f9c9f8701b93a Mon Sep 17 00:00:00 2001 From: jjbrunton Date: Sat, 8 Aug 2026 19:46:51 +0100 Subject: [PATCH] Sanitize realtime log metadata --- src/utils/json-safe.ts | 93 +++++++++++++++++++++++++++++++++++ src/web/log-broadcaster.ts | 6 ++- tests/log-broadcaster.test.ts | 33 +++++++++++++ 3 files changed, 130 insertions(+), 2 deletions(-) create mode 100644 src/utils/json-safe.ts diff --git a/src/utils/json-safe.ts b/src/utils/json-safe.ts new file mode 100644 index 0000000..ebfd803 --- /dev/null +++ b/src/utils/json-safe.ts @@ -0,0 +1,93 @@ +export type JsonPrimitive = string | number | boolean | null; +export type JsonSafeValue = + | JsonPrimitive + | JsonSafeValue[] + | { [key: string]: JsonSafeValue }; + +const REDACTED = '[Redacted]'; +const CIRCULAR = '[Circular]'; +const MAX_DEPTH = 6; +const MAX_COLLECTION_ENTRIES = 100; + +function isSensitiveKey(key: string): boolean { + return /^(?:authorization|cookie|set-cookie|password|passwd|secret|token|access[_-]?token|refresh[_-]?token|api[_-]?key)$/i.test(key); +} + +function constructorLabel(value: object): string { + const constructorName = value.constructor?.name; + return constructorName ? `[${constructorName}]` : '[Object]'; +} + +/** + * Convert arbitrary diagnostic metadata into a bounded, JSON-safe value. + * Errors intentionally expose only useful diagnostics; request/response and + * socket internals can be circular and may contain credentials. + */ +export function toJsonSafe( + value: unknown, + depth = 0, + ancestors = new WeakSet(), +): JsonSafeValue | undefined { + if (value === undefined) return undefined; + if (value === null || typeof value === 'string' || typeof value === 'boolean') return value; + if (typeof value === 'number') return Number.isFinite(value) ? value : String(value); + if (typeof value === 'bigint') return value.toString(); + if (typeof value === 'symbol') return value.description ? `[Symbol: ${value.description}]` : '[Symbol]'; + if (typeof value === 'function') return `[Function: ${value.name || 'anonymous'}]`; + + if (value instanceof Date) { + return Number.isNaN(value.getTime()) ? '[Invalid Date]' : value.toISOString(); + } + if (value instanceof Error) { + const result: { [key: string]: JsonSafeValue } = { + name: value.name, + message: value.message, + }; + if (value.stack) result.stack = value.stack; + const code = Reflect.get(value, 'code'); + if (typeof code === 'string' || typeof code === 'number') result.code = code; + if (value.cause !== undefined && depth < MAX_DEPTH) { + const cause = toJsonSafe(value.cause, depth + 1, ancestors); + if (cause !== undefined) result.cause = cause; + } + return result; + } + if (depth >= MAX_DEPTH) return constructorLabel(value); + if (ancestors.has(value)) return CIRCULAR; + + ancestors.add(value); + try { + if (Array.isArray(value)) { + const items = value + .slice(0, MAX_COLLECTION_ENTRIES) + .map((item) => toJsonSafe(item, depth + 1, ancestors) ?? null); + if (value.length > MAX_COLLECTION_ENTRIES) items.push(`[Truncated ${value.length - MAX_COLLECTION_ENTRIES} items]`); + return items; + } + + const prototype = Object.getPrototypeOf(value); + if (prototype !== Object.prototype && prototype !== null) { + return constructorLabel(value); + } + + const result: { [key: string]: JsonSafeValue } = {}; + const keys = Object.keys(value).slice(0, MAX_COLLECTION_ENTRIES); + for (const key of keys) { + if (isSensitiveKey(key)) { + result[key] = REDACTED; + continue; + } + try { + const child = toJsonSafe(Reflect.get(value, key), depth + 1, ancestors); + if (child !== undefined) result[key] = child; + } catch { + result[key] = '[Unreadable]'; + } + } + const omitted = Object.keys(value).length - keys.length; + if (omitted > 0) result.__truncated__ = `${omitted} properties omitted`; + return result; + } finally { + ancestors.delete(value); + } +} diff --git a/src/web/log-broadcaster.ts b/src/web/log-broadcaster.ts index 094b8a8..e2b024a 100644 --- a/src/web/log-broadcaster.ts +++ b/src/web/log-broadcaster.ts @@ -6,6 +6,7 @@ import { RealtimeEventHub, } from './events/index.js'; import { createExpressSseHandler } from './events/express-sse-adapter.js'; +import { toJsonSafe } from '../utils/json-safe.js'; const MAX_LOGS = 500; const INITIAL_LOGS_ON_CONNECT = 50; @@ -28,14 +29,15 @@ export class LogBroadcaster { } push(level: LogEntry['level'], message: string, data?: unknown): void { + const safeData = toJsonSafe(data); const realtimeEvent = this.eventHub.closed ? undefined - : this.eventHub.publish('log.created', { level, message, data }); + : this.eventHub.publish('log.created', { level, message, data: safeData }); const entry: LogEntry = { timestamp: realtimeEvent ? Date.parse(realtimeEvent.occurredAt) : Date.now(), level, message, - data, + data: safeData, }; this.entries.push(entry); if (this.entries.length > MAX_LOGS) { diff --git a/tests/log-broadcaster.test.ts b/tests/log-broadcaster.test.ts index 16225d8..f7fd448 100644 --- a/tests/log-broadcaster.test.ts +++ b/tests/log-broadcaster.test.ts @@ -34,4 +34,37 @@ describe('LogBroadcaster realtime bridge', () => { expect.objectContaining({ timestamp: occurredAt.getTime() }), ]); }); + + it('sanitizes circular network errors before buffering or publishing them', () => { + const hub = new RealtimeEventHub({ capacity: 10 }); + const received: RealtimeEvent[] = []; + hub.subscribe((event) => received.push(event)); + const broadcaster = new LogBroadcaster(hub); + const socket: Record = { constructorName: 'TLSSocket' }; + const parser: Record = { socket }; + socket.parser = parser; + const networkError = Object.assign(new Error('upstream request failed'), { + code: 'ECONNRESET', + request: socket, + }); + let serializedRecent = ''; + + broadcaster.push('error', 'Matrix request failed', { + error: networkError, + socket, + authorization: 'Bearer must-not-leak', + }); + broadcaster.recent( + { query: {} } as never, + { json: (value: unknown) => { serializedRecent = JSON.stringify(value); } } as never, + ); + + expect(() => JSON.stringify(received)).not.toThrow(); + expect(() => JSON.parse(serializedRecent)).not.toThrow(); + expect(serializedRecent).toContain('ECONNRESET'); + expect(serializedRecent).toContain('[Circular]'); + expect(serializedRecent).toContain('[Redacted]'); + expect(serializedRecent).not.toContain('must-not-leak'); + expect(serializedRecent).not.toContain('"request"'); + }); });