Sanitize realtime log metadata
This commit is contained in:
1 parent
492efe0f8a
commit
3d015c4e80
3 files changed
+130
-2
No files matched your search
@@ -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<object>(),
|
||||||
|
): 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
RealtimeEventHub,
|
RealtimeEventHub,
|
||||||
} from './events/index.js';
|
} from './events/index.js';
|
||||||
import { createExpressSseHandler } from './events/express-sse-adapter.js';
|
import { createExpressSseHandler } from './events/express-sse-adapter.js';
|
||||||
|
import { toJsonSafe } from '../utils/json-safe.js';
|
||||||
|
|
||||||
const MAX_LOGS = 500;
|
const MAX_LOGS = 500;
|
||||||
const INITIAL_LOGS_ON_CONNECT = 50;
|
const INITIAL_LOGS_ON_CONNECT = 50;
|
||||||
@@ -28,14 +29,15 @@ export class LogBroadcaster {
|
|||||||
}
|
}
|
||||||
|
|
||||||
push(level: LogEntry['level'], message: string, data?: unknown): void {
|
push(level: LogEntry['level'], message: string, data?: unknown): void {
|
||||||
|
const safeData = toJsonSafe(data);
|
||||||
const realtimeEvent = this.eventHub.closed
|
const realtimeEvent = this.eventHub.closed
|
||||||
? undefined
|
? undefined
|
||||||
: this.eventHub.publish('log.created', { level, message, data });
|
: this.eventHub.publish('log.created', { level, message, data: safeData });
|
||||||
const entry: LogEntry = {
|
const entry: LogEntry = {
|
||||||
timestamp: realtimeEvent ? Date.parse(realtimeEvent.occurredAt) : Date.now(),
|
timestamp: realtimeEvent ? Date.parse(realtimeEvent.occurredAt) : Date.now(),
|
||||||
level,
|
level,
|
||||||
message,
|
message,
|
||||||
data,
|
data: safeData,
|
||||||
};
|
};
|
||||||
this.entries.push(entry);
|
this.entries.push(entry);
|
||||||
if (this.entries.length > MAX_LOGS) {
|
if (this.entries.length > MAX_LOGS) {
|
||||||
|
|||||||
@@ -34,4 +34,37 @@ describe('LogBroadcaster realtime bridge', () => {
|
|||||||
expect.objectContaining({ timestamp: occurredAt.getTime() }),
|
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<string, unknown> = { constructorName: 'TLSSocket' };
|
||||||
|
const parser: Record<string, unknown> = { 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"');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
Reference in new issue
Block a user