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
This commit is contained in:
gorkem-bwl committed 2026-08-17 12:27:02 -04:00
1 parent f97ca8565a
commit 741ee7a92a
1 file changed
+72 -6
+72 -6
View File
@@ -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<string, unknown> = {};
for (const [key, entry] of Object.entries(value as Record<string, unknown>)) {
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));
}