From 741ee7a92ae222ee7877bbc73ffda3a3d9d38fb5 Mon Sep 17 00:00:00 2001 From: gorkem-bwl Date: Mon, 17 Aug 2026 12:27:02 -0400 Subject: [PATCH] fix(security): redact sensitive fields from client-side logs Failed requests logged the full request body to the browser console, so a failing POST to /auth/login wrote the user's plaintext password where it could be captured by error-reporting tools, screen shares, or support sessions. Redaction is applied centrally in the logger rather than at the call sites, so every current and future caller is covered: - createLogData() redacts context before it is serialized, covering the JSON branches of error() and warn(). - The debug-level branches of error() and warn() printed the raw context object directly, bypassing createLogData(); they now print the redacted copy. - debug() and info() redact their free-form data argument. Keys are matched case-insensitively on a substring after stripping dashes and underscores, so password, newPassword, X-Api-Key and Authorization are all caught. The walk copies rather than mutates the caller's object, cuts genuine cycles without mislabelling repeated siblings, and renders FormData/Blob as a type marker instead of walking them. Affected paths were POST (/auth/login, /auth/register) and PATCH/PUT, which carry monitor secrets. Error-level logging is on by default, so this was not limited to debug builds. Fixes #3843 --- client/src/Utils/logger.ts | 78 +++++++++++++++++++++++++++++++++++--- 1 file changed, 72 insertions(+), 6 deletions(-) diff --git a/client/src/Utils/logger.ts b/client/src/Utils/logger.ts index 74be87918..c70769db4 100644 --- a/client/src/Utils/logger.ts +++ b/client/src/Utils/logger.ts @@ -44,6 +44,70 @@ const formatError = (error?: Error) => { }; }; +/** + * 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 + * by `password` and `apikey` respectively. + */ +const SENSITIVE_KEY_PATTERNS = [ + "password", + "secret", + "token", + "authorization", + "apikey", + "credential", + "passphrase", +] 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) + ); +}; + +/** + * 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. + * + * `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. + */ +const redactSensitive = (value: unknown, ancestors: readonly object[] = []): unknown => { + if (value === null || typeof value !== "object") return value; + + if (ancestors.includes(value)) return "[CIRCULAR]"; + + if ( + typeof FormData !== "undefined" && + (value instanceof FormData || value instanceof Blob) + ) { + return `[${value.constructor.name}]`; + } + + const path = [...ancestors, value]; + + if (Array.isArray(value)) { + return value.map((item) => redactSensitive(item, path)); + } + + const prototype = Object.getPrototypeOf(value); + if (prototype !== Object.prototype && prototype !== null) { + return value; + } + + const result: Record = {}; + for (const [key, entry] of Object.entries(value as Record)) { + result[key] = isSensitiveKey(key) ? REDACTED : redactSensitive(entry, path); + } + return result; +}; + const createLogData = ( level: LogLevel, message: string, @@ -55,7 +119,7 @@ const createLogData = ( level, message, error: formatError(error), - context, + context: context === undefined ? undefined : (redactSensitive(context) as LogContext), url: window.location.href, userAgent: navigator.userAgent, }; @@ -72,7 +136,7 @@ const error = (message: string, error?: Error, context?: LogContext): void => { console.error("Error:", error); } if (context && Object.keys(context).length > 0) { - console.log("Context:", context); + console.log("Context:", logData.context); } console.log("URL:", logData.url); console.log("Timestamp:", logData.timestamp); @@ -90,7 +154,7 @@ const warn = (message: string, context?: LogContext): void => { if (configuredLevel === "debug") { console.group(`WARN: ${message}`); if (context && Object.keys(context).length > 0) { - console.log("Context:", context); + console.log("Context:", logData.context); } console.groupEnd(); } else { @@ -103,7 +167,7 @@ const debug = (message: string, data?: any): void => { console.group(`DEBUG: ${message}`); if (data !== undefined) { - console.log(data); + console.log(redactSensitive(data)); } console.groupEnd(); }; @@ -111,14 +175,16 @@ const debug = (message: string, data?: any): void => { const info = (message: string, data?: any): void => { if (!shouldLog("info")) return; + const safeData = data === undefined ? undefined : redactSensitive(data); + if (configuredLevel === "debug") { - console.log(`INFO: ${message}`, data); + console.log(`INFO: ${message}`, safeData); } else { const logData = { timestamp: new Date().toISOString(), level: "info" as LogLevel, message, - data, + data: safeData, }; console.log(JSON.stringify(logData)); }