mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-07-01 11:53:55 +00:00
fix: stripHeavyErrorFields Winston format (defense-in-depth) (#14018)
Some checks are pending
Docker Dev Branch Images Build / build (Dockerfile, lc-dev, node) (push) Waiting to run
Docker Dev Branch Images Build / build (Dockerfile.multi, lc-dev-api, api-build) (push) Waiting to run
GitNexus Index / index (push) Waiting to run
GitNexus Index / post-index (push) Blocked by required conditions
Some checks are pending
Docker Dev Branch Images Build / build (Dockerfile, lc-dev, node) (push) Waiting to run
Docker Dev Branch Images Build / build (Dockerfile.multi, lc-dev-api, api-build) (push) Waiting to run
GitNexus Index / index (push) Waiting to run
GitNexus Index / post-index (push) Blocked by required conditions
This commit is contained in:
parent
84329ab0ff
commit
c00fb2d73d
3 changed files with 397 additions and 2 deletions
|
|
@ -21,6 +21,23 @@ const MAX_REDACTION_STRING_LENGTH = Math.max(
|
|||
);
|
||||
const MAX_REDACTION_BUFFER_BYTES = MAX_REDACTION_STRING_LENGTH;
|
||||
|
||||
const HEAVY_ERROR_KEYS = new Set<string>([
|
||||
'httpsAgent',
|
||||
'httpAgent',
|
||||
'agent',
|
||||
'socket',
|
||||
'sockets',
|
||||
'_httpMessage',
|
||||
'_httpAgent',
|
||||
'parser',
|
||||
'_tlsOptions',
|
||||
'_handle',
|
||||
'ssl',
|
||||
]);
|
||||
const AXIOS_ONLY_HEAVY_KEYS = new Set<string>(['config', 'request']);
|
||||
const MAX_STRIP_DEPTH = 6;
|
||||
const PRESERVED_ERROR_PROPS = ['message', 'stack', 'name', 'code'] as const;
|
||||
|
||||
const sensitiveKeys: RegExp[] = [
|
||||
/\b(sk-)[a-zA-Z0-9_-]+/g, // OpenAI API key pattern
|
||||
/\b(Bearer )[^\s"']+/g, // Header: Bearer token pattern
|
||||
|
|
@ -505,6 +522,154 @@ const debugTraverse: winston.Logform.Format = winston.format.printf(
|
|||
},
|
||||
);
|
||||
|
||||
const isErrorLike = (value: object): boolean => {
|
||||
if (value instanceof Error) {
|
||||
return true;
|
||||
}
|
||||
const record = value as Record<string, unknown>;
|
||||
if (record.isAxiosError === true) {
|
||||
return true;
|
||||
}
|
||||
if (typeof record.stack === 'string') {
|
||||
return true;
|
||||
}
|
||||
return typeof record.name === 'string' && record.name.endsWith('Error');
|
||||
};
|
||||
|
||||
const compactRequestInfo = (config: unknown): { method?: unknown; url?: unknown } | undefined => {
|
||||
if (config == null || typeof config !== 'object') {
|
||||
return undefined;
|
||||
}
|
||||
const { method, url } = config as Record<string, unknown>;
|
||||
if (method === undefined && url === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
return { method, url };
|
||||
};
|
||||
|
||||
const compactResponse = (response: unknown): Record<string, unknown> | undefined => {
|
||||
if (response == null || typeof response !== 'object') {
|
||||
return undefined;
|
||||
}
|
||||
const { status, statusText, headers, data } = response as Record<string, unknown>;
|
||||
return { status, statusText, headers, data };
|
||||
};
|
||||
|
||||
const sanitizeErrorNode = (node: Record<string, unknown>): Record<string, unknown> => {
|
||||
const sanitized: Record<string, unknown> = {};
|
||||
const nodeIsAxios = node.isAxiosError === true;
|
||||
|
||||
for (const key of Object.keys(node)) {
|
||||
if (HEAVY_ERROR_KEYS.has(key) || (nodeIsAxios && AXIOS_ONLY_HEAVY_KEYS.has(key))) {
|
||||
continue;
|
||||
}
|
||||
if (nodeIsAxios && key === 'response') {
|
||||
const response = compactResponse(node.response);
|
||||
if (response !== undefined) {
|
||||
sanitized.response = response;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
sanitized[key] = node[key];
|
||||
}
|
||||
|
||||
if (nodeIsAxios) {
|
||||
const requestInfo = compactRequestInfo(node.config);
|
||||
if (requestInfo !== undefined) {
|
||||
sanitized.requestInfo = requestInfo;
|
||||
}
|
||||
}
|
||||
|
||||
for (const key of PRESERVED_ERROR_PROPS) {
|
||||
const value = (node as Record<string, unknown>)[key];
|
||||
if (value !== undefined) {
|
||||
sanitized[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
return sanitized;
|
||||
};
|
||||
|
||||
const stripHeavy = (value: unknown, depth: number, seen: WeakSet<object>): unknown => {
|
||||
if (value == null || typeof value !== 'object') {
|
||||
return value;
|
||||
}
|
||||
if (depth > MAX_STRIP_DEPTH) {
|
||||
return value;
|
||||
}
|
||||
if (seen.has(value)) {
|
||||
return '[Circular]';
|
||||
}
|
||||
// Ancestor-path tracking (added on entry, removed on exit) so genuinely cyclic
|
||||
// references are caught without collapsing benign objects shared between siblings.
|
||||
seen.add(value);
|
||||
|
||||
let result: unknown;
|
||||
if (Array.isArray(value)) {
|
||||
result = value.map((item) => stripHeavy(item, depth + 1, seen));
|
||||
} else if (isErrorLike(value)) {
|
||||
const working = sanitizeErrorNode(value as Record<string, unknown>);
|
||||
for (const key of Object.keys(working)) {
|
||||
working[key] = stripHeavy(working[key], depth + 1, seen);
|
||||
}
|
||||
result = working;
|
||||
} else if (Object.isFrozen(value)) {
|
||||
result = value;
|
||||
} else {
|
||||
const working = { ...(value as Record<string, unknown>) };
|
||||
for (const key of Object.keys(working)) {
|
||||
working[key] = stripHeavy(working[key], depth + 1, seen);
|
||||
}
|
||||
result = working;
|
||||
}
|
||||
|
||||
seen.delete(value);
|
||||
return result;
|
||||
};
|
||||
|
||||
/**
|
||||
* Strips heavy, non-serializable fields (e.g. AxiosError `config`/`httpsAgent`,
|
||||
* sockets, TLS internals) from error-like log nodes before serialization, while
|
||||
* preserving a compact `requestInfo`, a compact `response`, and the error's
|
||||
* message/stack/name/code. Operates on copies and never mutates caller-owned objects.
|
||||
*/
|
||||
const stripHeavyErrorFields: winston.Logform.FormatWrap = winston.format(
|
||||
(info: winston.Logform.TransformableInfo) => {
|
||||
if (info.level !== 'error' && info.level !== 'warn') {
|
||||
return info;
|
||||
}
|
||||
try {
|
||||
const seen = new WeakSet<object>();
|
||||
// Winston merges a logged error's enumerable props (config/httpsAgent/...) onto
|
||||
// the top-level info object when the message has no format token, so the info
|
||||
// node itself must be sanitized as an error-like node, not just its values.
|
||||
const base = isErrorLike(info)
|
||||
? sanitizeErrorNode(info as unknown as Record<string, unknown>)
|
||||
: { ...(info as Record<string, unknown>) };
|
||||
const result = base as Record<string | symbol, unknown>;
|
||||
|
||||
for (const key of Object.keys(result)) {
|
||||
result[key] = stripHeavy(result[key], 0, seen);
|
||||
}
|
||||
|
||||
// sanitizeErrorNode rebuilds from enumerable string keys only; re-attach the
|
||||
// reserved winston symbols (LEVEL/MESSAGE) that downstream transports read.
|
||||
for (const sym of Object.getOwnPropertySymbols(info)) {
|
||||
result[sym] = (info as Record<string | symbol, unknown>)[sym];
|
||||
}
|
||||
|
||||
const splat = (info as Record<string | symbol, unknown>)[SPLAT_SYMBOL];
|
||||
if (Array.isArray(splat)) {
|
||||
result[SPLAT_SYMBOL] = splat.map((item) => stripHeavy(item, 0, seen));
|
||||
}
|
||||
|
||||
return result as winston.Logform.TransformableInfo;
|
||||
} catch {
|
||||
return info;
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* Truncates long string values in JSON log objects.
|
||||
* Prevents outputting extremely long values (e.g., base64, blobs).
|
||||
|
|
@ -548,4 +713,4 @@ const jsonTruncateFormat: winston.Logform.FormatWrap = winston.format(
|
|||
},
|
||||
);
|
||||
|
||||
export { redactFormat, redactMessage, debugTraverse, jsonTruncateFormat };
|
||||
export { redactFormat, redactMessage, debugTraverse, jsonTruncateFormat, stripHeavyErrorFields };
|
||||
|
|
|
|||
223
packages/data-schemas/src/config/winston.spec.ts
Normal file
223
packages/data-schemas/src/config/winston.spec.ts
Normal file
|
|
@ -0,0 +1,223 @@
|
|||
import winston from 'winston';
|
||||
import { stripHeavyErrorFields } from './parsers';
|
||||
|
||||
const SPLAT = Symbol.for('splat');
|
||||
|
||||
const buildAxiosErrorWithHeavyAgent = (): Error => {
|
||||
const sockets: Record<string, unknown> = {};
|
||||
for (let i = 0; i < 500; i++) {
|
||||
sockets[`socket-${i}`] = {
|
||||
_tlsOptions: { session: 'A'.repeat(1024) },
|
||||
bytesRead: i,
|
||||
};
|
||||
}
|
||||
const httpsAgent = {
|
||||
sockets,
|
||||
freeSockets: sockets,
|
||||
_tlsOptions: { session: 'B'.repeat(2048) },
|
||||
maxSockets: Infinity,
|
||||
};
|
||||
const config = {
|
||||
method: 'post',
|
||||
url: 'http://rag-api/query',
|
||||
httpsAgent,
|
||||
headers: { Authorization: 'Bearer secret' },
|
||||
};
|
||||
|
||||
const error = new Error('Request failed with status code 404') as Error & {
|
||||
name: string;
|
||||
code: string;
|
||||
isAxiosError: boolean;
|
||||
config: unknown;
|
||||
request: unknown;
|
||||
response: unknown;
|
||||
httpsAgent: unknown;
|
||||
};
|
||||
error.name = 'AxiosError';
|
||||
error.code = 'ERR_BAD_REQUEST';
|
||||
error.isAxiosError = true;
|
||||
error.config = config;
|
||||
error.request = { socket: { _handle: {} }, agent: httpsAgent };
|
||||
error.response = {
|
||||
status: 404,
|
||||
statusText: 'Not Found',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
data: { detail: 'not found' },
|
||||
config,
|
||||
request: { agent: httpsAgent },
|
||||
};
|
||||
error.httpsAgent = httpsAgent;
|
||||
return error;
|
||||
};
|
||||
|
||||
describe('stripHeavyErrorFields', () => {
|
||||
const runTransform = (info: Record<string | symbol, unknown>) => {
|
||||
const format = stripHeavyErrorFields();
|
||||
return format.transform(
|
||||
info as unknown as import('winston').Logform.TransformableInfo,
|
||||
format.options,
|
||||
);
|
||||
};
|
||||
|
||||
it('drops config/httpsAgent from a logged AxiosError while keeping message/stack/status', () => {
|
||||
const error = buildAxiosErrorWithHeavyAgent();
|
||||
const info = {
|
||||
level: 'error',
|
||||
message: 'Error encountered in `file_search` while querying file:',
|
||||
[SPLAT]: [error],
|
||||
} as Record<string | symbol, unknown>;
|
||||
|
||||
const out = runTransform(info) as Record<string | symbol, unknown>;
|
||||
const sanitizedError = (out[SPLAT] as unknown[])[0];
|
||||
const serialized = JSON.stringify(sanitizedError);
|
||||
|
||||
expect(serialized).not.toContain('httpsAgent');
|
||||
expect(serialized).not.toContain('_tlsOptions');
|
||||
expect(serialized).not.toContain('sockets');
|
||||
expect(serialized).not.toContain('freeSockets');
|
||||
expect(serialized).not.toMatch(/"config"/);
|
||||
|
||||
const parsed = JSON.parse(serialized);
|
||||
expect(parsed.message).toBe('Request failed with status code 404');
|
||||
expect(typeof parsed.stack).toBe('string');
|
||||
expect(parsed.name).toBe('AxiosError');
|
||||
expect(parsed.code).toBe('ERR_BAD_REQUEST');
|
||||
expect(parsed.requestInfo).toEqual({ method: 'post', url: 'http://rag-api/query' });
|
||||
expect(parsed.response.status).toBe(404);
|
||||
expect(parsed.response.statusText).toBe('Not Found');
|
||||
expect(parsed.response.data).toEqual({ detail: 'not found' });
|
||||
|
||||
// Everything except the (intentionally kept) stack trace is tiny: the heavy
|
||||
// object graph is gone.
|
||||
const { stack: _stack, ...withoutStack } = parsed;
|
||||
expect(JSON.stringify(withoutStack).length).toBeLessThan(1024);
|
||||
|
||||
// The sanitized error is a tiny fraction of the raw object graph
|
||||
// (the raw AxiosError serializes its socket/TLS pool into hundreds of KB).
|
||||
const rawSize = JSON.stringify(error).length;
|
||||
expect(serialized.length).toBeLessThan(rawSize / 50);
|
||||
});
|
||||
|
||||
it('does not mutate the caller-owned error object', () => {
|
||||
const error = buildAxiosErrorWithHeavyAgent();
|
||||
const info = {
|
||||
level: 'error',
|
||||
message: 'boom',
|
||||
[SPLAT]: [error],
|
||||
} as Record<string | symbol, unknown>;
|
||||
|
||||
runTransform(info);
|
||||
|
||||
// Original error is untouched (downstream code may still rethrow/read it).
|
||||
expect((error as unknown as { config: unknown }).config).toBeDefined();
|
||||
expect((error as unknown as { httpsAgent: unknown }).httpsAgent).toBeDefined();
|
||||
});
|
||||
|
||||
it('leaves non-error levels untouched', () => {
|
||||
const info = { level: 'info', message: 'hello' } as Record<string | symbol, unknown>;
|
||||
const out = runTransform(info);
|
||||
expect(out).toBe(info);
|
||||
});
|
||||
|
||||
it('strips heavy fields hoisted to top-level info by winston for `logger.error(msg, err)`', () => {
|
||||
// A token-less message makes winston merge the error's enumerable props
|
||||
// (config/httpsAgent/...) onto the top-level info object, not just into [splat].
|
||||
const captured: unknown[] = [];
|
||||
const capture = winston.format((info) => {
|
||||
captured.push(JSON.parse(JSON.stringify(info)));
|
||||
return info;
|
||||
});
|
||||
const logger = winston.createLogger({
|
||||
level: 'error',
|
||||
format: winston.format.combine(
|
||||
winston.format.errors({ stack: true }),
|
||||
stripHeavyErrorFields(),
|
||||
capture(),
|
||||
winston.format.splat(),
|
||||
),
|
||||
transports: [new winston.transports.Console({ silent: true })],
|
||||
});
|
||||
|
||||
logger.error(
|
||||
'Error encountered in `file_search` while querying file:',
|
||||
buildAxiosErrorWithHeavyAgent(),
|
||||
);
|
||||
|
||||
const serialized = JSON.stringify(captured[0]);
|
||||
expect(serialized).not.toContain('httpsAgent');
|
||||
expect(serialized).not.toContain('_tlsOptions');
|
||||
expect(serialized).not.toContain('sockets');
|
||||
expect(serialized).not.toMatch(/"config"/);
|
||||
expect(serialized.length).toBeLessThan(4096);
|
||||
|
||||
const record = captured[0] as Record<string, unknown>;
|
||||
expect(record.requestInfo).toEqual({ method: 'post', url: 'http://rag-api/query' });
|
||||
expect((record.response as Record<string, unknown>).status).toBe(404);
|
||||
expect(typeof record.message).toBe('string');
|
||||
});
|
||||
|
||||
it('preserves config/request/response on a non-Axios error while still stripping agent internals', () => {
|
||||
const sockets: Record<string, unknown> = { s0: { _tlsOptions: { session: 'X'.repeat(2048) } } };
|
||||
const httpsAgent = {
|
||||
sockets,
|
||||
freeSockets: sockets,
|
||||
_tlsOptions: { session: 'Y'.repeat(2048) },
|
||||
};
|
||||
const error = new Error('ENOENT: no such file or directory') as Error & {
|
||||
httpsAgent: unknown;
|
||||
config: unknown;
|
||||
request: unknown;
|
||||
response: unknown;
|
||||
};
|
||||
error.httpsAgent = httpsAgent;
|
||||
error.config = { method: 'get', url: 'http://internal/resource' };
|
||||
error.request = { id: 'req-123' };
|
||||
error.response = { status: 503, statusText: 'Service Unavailable', traceId: 'trace-abc' };
|
||||
|
||||
const info = {
|
||||
level: 'error',
|
||||
message: 'non-axios failure',
|
||||
[SPLAT]: [error],
|
||||
} as Record<string | symbol, unknown>;
|
||||
|
||||
const out = runTransform(info) as Record<string | symbol, unknown>;
|
||||
const sanitizedError = (out[SPLAT] as unknown[])[0];
|
||||
const serialized = JSON.stringify(sanitizedError);
|
||||
|
||||
expect(serialized).not.toContain('httpsAgent');
|
||||
expect(serialized).not.toContain('_tlsOptions');
|
||||
expect(serialized).not.toContain('freeSockets');
|
||||
|
||||
const parsed = JSON.parse(serialized);
|
||||
expect(parsed.config).toEqual({ method: 'get', url: 'http://internal/resource' });
|
||||
expect(parsed.request).toEqual({ id: 'req-123' });
|
||||
expect(parsed.response).toEqual({
|
||||
status: 503,
|
||||
statusText: 'Service Unavailable',
|
||||
traceId: 'trace-abc',
|
||||
});
|
||||
expect(parsed.requestInfo).toBeUndefined();
|
||||
expect(parsed.message).toBe('ENOENT: no such file or directory');
|
||||
});
|
||||
|
||||
it('still reduces a genuine AxiosError (config dropped, requestInfo derived, response compacted)', () => {
|
||||
const error = buildAxiosErrorWithHeavyAgent();
|
||||
const info = {
|
||||
level: 'error',
|
||||
message: 'axios failure',
|
||||
[SPLAT]: [error],
|
||||
} as Record<string | symbol, unknown>;
|
||||
|
||||
const out = runTransform(info) as Record<string | symbol, unknown>;
|
||||
const sanitizedError = (out[SPLAT] as unknown[])[0];
|
||||
const serialized = JSON.stringify(sanitizedError);
|
||||
|
||||
expect(serialized).not.toContain('httpsAgent');
|
||||
expect(serialized).not.toMatch(/"config"/);
|
||||
|
||||
const parsed = JSON.parse(serialized);
|
||||
expect(parsed.config).toBeUndefined();
|
||||
expect(parsed.requestInfo).toEqual({ method: 'post', url: 'http://rag-api/query' });
|
||||
expect(parsed.response.status).toBe(404);
|
||||
});
|
||||
});
|
||||
|
|
@ -1,6 +1,12 @@
|
|||
import winston from 'winston';
|
||||
import 'winston-daily-rotate-file';
|
||||
import { redactFormat, redactMessage, debugTraverse, jsonTruncateFormat } from './parsers';
|
||||
import {
|
||||
redactFormat,
|
||||
redactMessage,
|
||||
debugTraverse,
|
||||
jsonTruncateFormat,
|
||||
stripHeavyErrorFields,
|
||||
} from './parsers';
|
||||
import { getTenantId, getUserId, getRequestId, SYSTEM_TENANT_ID } from './tenantContext';
|
||||
import { getLogDirectory } from './utils';
|
||||
|
||||
|
|
@ -84,6 +90,7 @@ const fileFormat = winston.format.combine(
|
|||
redactFormat(),
|
||||
winston.format.timestamp({ format: () => new Date().toISOString() }),
|
||||
winston.format.errors({ stack: true }),
|
||||
stripHeavyErrorFields(),
|
||||
winston.format.splat(),
|
||||
requestContextFormat(),
|
||||
);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue