diff --git a/client/src/Utils/logger.ts b/client/src/Utils/logger.ts index c70769db4..1b7d7a7a5 100644 --- a/client/src/Utils/logger.ts +++ b/client/src/Utils/logger.ts @@ -16,11 +16,7 @@ interface ErrorLogData { timestamp: string; level: LogLevel; message: string; - error?: { - message: string; - name: string; - stack?: string; - }; + error?: Record; context?: LogContext; url: string; userAgent: string; @@ -34,16 +30,6 @@ const shouldLog = (level: LogLevel): boolean => { return LOG_LEVEL_PRIORITY[level] >= configuredPriority; }; -const formatError = (error?: Error) => { - if (!error) return undefined; - - return { - message: error.message, - name: error.name, - stack: error.stack, - }; -}; - /** * Keys whose values must never reach the console. Matched case-insensitively * against a substring of the key, so `newPassword` and `X-Api-Key` are covered @@ -51,29 +37,45 @@ const formatError = (error?: Error) => { */ const SENSITIVE_KEY_PATTERNS = [ "password", + "passwd", + "pwd", "secret", "token", "authorization", "apikey", "credential", "passphrase", + "cookie", + "jwt", + "privatekey", + "sessionid", + "csrf", ] as const; -type SensitiveKeyPattern = (typeof SENSITIVE_KEY_PATTERNS)[number]; const REDACTED = "[REDACTED]"; const isSensitiveKey = (key: string): boolean => { const normalized = key.toLowerCase().replace(/[-_]/g, ""); - return SENSITIVE_KEY_PATTERNS.some((pattern: SensitiveKeyPattern) => - normalized.includes(pattern) - ); + return SENSITIVE_KEY_PATTERNS.some((pattern) => normalized.includes(pattern)); }; /** - * Deep-copies a value with sensitive fields replaced by a placeholder. Guards - * against cycles so a self-referencing payload cannot hang the logger, and - * leaves non-plain objects (FormData, File, Blob) as a type marker rather than - * walking them. + * Opaque browser types that must not be walked: they carry no useful log data + * and may hold large buffers. Resolved defensively because these globals are + * absent in SSR and some test environments. + */ +const isOpaqueObject = (value: object): boolean => { + const opaqueTypes = ["FormData", "Blob", "File", "ArrayBuffer"] as const; + return opaqueTypes.some((name) => { + const ctor = (globalThis as Record)[name]; + return typeof ctor === "function" && value instanceof (ctor as new () => object); + }); +}; + +/** + * Deep-copies a value with sensitive fields replaced by a placeholder. Walks + * class instances as well as object literals, so a credential on a DTO is + * redacted too. Opaque types (FormData, Blob) render as a type marker. * * `ancestors` tracks only the current path, so the same object appearing twice * as a sibling is still rendered in full — only a genuine cycle is cut. @@ -83,12 +85,10 @@ const redactSensitive = (value: unknown, ancestors: readonly object[] = []): unk if (ancestors.includes(value)) return "[CIRCULAR]"; - if ( - typeof FormData !== "undefined" && - (value instanceof FormData || value instanceof Blob) - ) { - return `[${value.constructor.name}]`; - } + if (isOpaqueObject(value)) return `[${value.constructor?.name ?? "Object"}]`; + + // Dates and RegExps stringify usefully on their own; walking them yields nothing. + if (value instanceof Date || value instanceof RegExp) return value; const path = [...ancestors, value]; @@ -96,11 +96,8 @@ const redactSensitive = (value: unknown, ancestors: readonly object[] = []): unk return value.map((item) => redactSensitive(item, path)); } - const prototype = Object.getPrototypeOf(value); - if (prototype !== Object.prototype && prototype !== null) { - return value; - } - + // Walk own enumerable properties of ANY object, including class instances — + // a credential on a DTO must be redacted just as it is on an object literal. const result: Record = {}; for (const [key, entry] of Object.entries(value as Record)) { result[key] = isSensitiveKey(key) ? REDACTED : redactSensitive(entry, path); @@ -108,6 +105,54 @@ const redactSensitive = (value: unknown, ancestors: readonly object[] = []): unk return result; }; +/** + * Parses a serialized request body so its fields can be redacted by key. A body + * that is not JSON is replaced wholesale rather than logged raw, since an + * unparseable payload (e.g. form-encoded) may still carry credentials. + */ +const safeJsonParse = (raw: string): unknown => { + try { + const parsed = JSON.parse(raw); + return typeof parsed === "object" && parsed !== null ? parsed : "[UNPARSED BODY]"; + } catch { + return "[UNPARSED BODY]"; + } +}; + +/** + * Errors are logged as-is by the debug branches, but an AxiosError carries the + * outgoing request on `config` — including the serialized body and the + * Authorization header — so the raw object cannot be handed to the console. + * Returns a redacted plain object safe to print. + */ +const toSafeError = (error: Error): Record => { + const candidate = error as unknown as { + config?: { url?: string; method?: string; data?: unknown; headers?: unknown }; + response?: { status?: number }; + }; + + const safe: Record = { + name: error.name, + message: error.message, + stack: error.stack, + }; + + if (candidate.config) { + const { url, method, data, headers } = candidate.config; + safe.config = { + url, + method, + // Axios serializes the request body to a string, so key-based + // redaction cannot reach into it — parse it back first. + data: redactSensitive(typeof data === "string" ? safeJsonParse(data) : data), + headers: redactSensitive(headers), + }; + safe.status = candidate.response?.status; + } + + return redactSensitive(safe) as Record; +}; + const createLogData = ( level: LogLevel, message: string, @@ -118,7 +163,7 @@ const createLogData = ( timestamp: new Date().toISOString(), level, message, - error: formatError(error), + error: error === undefined ? undefined : toSafeError(error), context: context === undefined ? undefined : (redactSensitive(context) as LogContext), url: window.location.href, userAgent: navigator.userAgent, @@ -133,7 +178,7 @@ const error = (message: string, error?: Error, context?: LogContext): void => { if (configuredLevel === "debug") { console.group(`ERROR: ${message}`); if (error) { - console.error("Error:", error); + console.error("Error:", toSafeError(error)); } if (context && Object.keys(context).length > 0) { console.log("Context:", logData.context);