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:
1 parent
f97ca8565a
commit
741ee7a92a
1 file changed
+72
-6
@@ -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));
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user