mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-08-04 06:52:47 +00:00
🧼 fix: Prevent Shared Link Caching and Strengthen Log Redaction (#13561)
* fix: tighten share caching and log redaction * fix: sort changed imports * fix: redact splat log arguments * fix: avoid mutating log metadata during redaction * fix: redact error and api_key log values * fix: preserve error log context during redaction * fix: cover remaining log redaction paths * fix: bound log redaction work * fix: align redaction scan cap with log config
This commit is contained in:
parent
265d660076
commit
4b871a11ad
6 changed files with 601 additions and 47 deletions
|
|
@ -66,7 +66,12 @@ jest.mock('~/server/middleware/requireJwtAuth', () => (req, res, next) => next()
|
|||
const { RetentionMode } = require('librechat-data-provider');
|
||||
const { createTempChatExpirationDate, logger } = require('@librechat/data-schemas');
|
||||
const { deleteSharedLinkWithCleanup } = require('@librechat/api');
|
||||
const { createSharedLink, updateSharedLink, getRoleByName } = require('~/models');
|
||||
const {
|
||||
getSharedMessages,
|
||||
createSharedLink,
|
||||
updateSharedLink,
|
||||
getRoleByName,
|
||||
} = require('~/models');
|
||||
const shareRouter = require('../share');
|
||||
|
||||
const activeExpiration = new Date('2030-01-01T00:00:00.000Z');
|
||||
|
|
@ -101,6 +106,15 @@ describe('share routes retention', () => {
|
|||
mockGrantCreationPermissions.mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
it('prevents successful shared message responses from being cached', async () => {
|
||||
getSharedMessages.mockResolvedValue({ shareId: 'share-123', messages: [] });
|
||||
|
||||
const response = await request(buildApp()).get('/api/share/share-123');
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.headers['cache-control']).toBe('private, no-store');
|
||||
});
|
||||
|
||||
it('expires new shares for retained non-temporary conversations', async () => {
|
||||
mockGetSharedLinkExpiration.mockResolvedValue(activeExpiration);
|
||||
createSharedLink.mockResolvedValue({ _id: 'link-123', shareId: 'share-123' });
|
||||
|
|
|
|||
|
|
@ -58,6 +58,7 @@ if (allowSharedLinks) {
|
|||
try {
|
||||
const share = await getSharedMessages(req.params.shareId, req.shareResourceId);
|
||||
if (share) {
|
||||
res.set('Cache-Control', 'private, no-store');
|
||||
res.status(200).json(share);
|
||||
} else {
|
||||
res.status(404).end();
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { debugTraverse } from './parsers';
|
||||
import winston from 'winston';
|
||||
import { debugTraverse, redactFormat, redactMessage } from './parsers';
|
||||
|
||||
const SPLAT_SYMBOL = Symbol.for('splat');
|
||||
const MESSAGE_SYMBOL = Symbol.for('message');
|
||||
|
|
@ -9,6 +10,20 @@ type FormatterInfo = Record<string | symbol, unknown> & {
|
|||
timestamp: string;
|
||||
};
|
||||
|
||||
type RedactInfo = Record<string | symbol, unknown> & {
|
||||
level: string;
|
||||
message: unknown;
|
||||
};
|
||||
|
||||
function runRedactFormat(info: RedactInfo): RedactInfo {
|
||||
return (redactFormat().transform(info) || info) as RedactInfo;
|
||||
}
|
||||
|
||||
function runRedactSplatFormat(info: RedactInfo): RedactInfo {
|
||||
const format = winston.format.combine(redactFormat(), winston.format.splat());
|
||||
return (format.transform(info) || info) as RedactInfo;
|
||||
}
|
||||
|
||||
function runFormatter(info: FormatterInfo): string {
|
||||
const transformed = debugTraverse.transform(info);
|
||||
if (transformed && typeof transformed === 'object') {
|
||||
|
|
@ -28,6 +43,240 @@ function buildInfo(level: string, meta: Record<string, unknown>): FormatterInfo
|
|||
};
|
||||
}
|
||||
|
||||
describe('redactMessage', () => {
|
||||
it('redacts sensitive token patterns anywhere in a message', () => {
|
||||
expect(redactMessage('token: sk-abc123def')).toBe('token: sk-[REDACTED]');
|
||||
expect(redactMessage('auth Bearer secretvalue')).toBe('auth Bearer [REDACTED]');
|
||||
expect(redactMessage('api-key: secretvalue')).toBe('api-key: [REDACTED]');
|
||||
expect(redactMessage('https://example.test/?key=secretvalue&next=true')).toBe(
|
||||
'https://example.test/?key=[REDACTED]&next=true',
|
||||
);
|
||||
expect(redactMessage('https://example.test/?api_key=secretvalue&next=true')).toBe(
|
||||
'https://example.test/?api_key=[REDACTED]&next=true',
|
||||
);
|
||||
});
|
||||
|
||||
it('does not redact ordinary words containing sensitive prefixes', () => {
|
||||
expect(redactMessage('task-runner failed')).toBe('task-runner failed');
|
||||
expect(redactMessage('mask-value computed')).toBe('mask-value computed');
|
||||
expect(redactMessage('monkey=10 bananas')).toBe('monkey=10 bananas');
|
||||
});
|
||||
});
|
||||
|
||||
describe('redactFormat', () => {
|
||||
it.each(['error', 'warn', 'info', 'debug'])('redacts info.message for %s level', (level) => {
|
||||
const info = runRedactFormat({ level, message: 'Bearer secretvalue' });
|
||||
expect(info.message).toBe('Bearer [REDACTED]');
|
||||
});
|
||||
|
||||
it('redacts the winston message symbol', () => {
|
||||
const info = runRedactFormat({
|
||||
level: 'info',
|
||||
message: 'visible',
|
||||
[MESSAGE_SYMBOL]: 'token: sk-abc123def',
|
||||
});
|
||||
|
||||
expect(info[MESSAGE_SYMBOL]).toBe('token: sk-[REDACTED]');
|
||||
});
|
||||
|
||||
it('redacts splat arguments before winston interpolates them', () => {
|
||||
const info = runRedactSplatFormat({
|
||||
level: 'info',
|
||||
message: 'token %s',
|
||||
[SPLAT_SYMBOL]: ['sk-abc123def'],
|
||||
});
|
||||
|
||||
expect(info.message).toBe('token sk-[REDACTED]');
|
||||
});
|
||||
|
||||
it('redacts string values in splat metadata', () => {
|
||||
const metadata = {
|
||||
auth: 'Bearer secretvalue',
|
||||
nested: { url: 'https://example.test/?key=secretvalue&next=true' },
|
||||
};
|
||||
const info = runRedactFormat({
|
||||
level: 'info',
|
||||
message: 'visible',
|
||||
[SPLAT_SYMBOL]: [metadata],
|
||||
});
|
||||
const splat = info[SPLAT_SYMBOL] as Array<{ auth: string; nested: { url: string } }>;
|
||||
|
||||
expect(splat[0].auth).toBe('Bearer [REDACTED]');
|
||||
expect(splat[0].nested.url).toBe('https://example.test/?key=[REDACTED]&next=true');
|
||||
expect(metadata.auth).toBe('Bearer secretvalue');
|
||||
expect(metadata.nested.url).toBe('https://example.test/?key=secretvalue&next=true');
|
||||
});
|
||||
|
||||
it('redacts values under sensitive splat metadata keys', () => {
|
||||
const metadata = {
|
||||
apiKey: 'secretvalue',
|
||||
authorization: 'secretvalue',
|
||||
nested: { token: 'secretvalue' },
|
||||
safe: 'secretvalue',
|
||||
'x-api-key': 'secretvalue',
|
||||
};
|
||||
const info = runRedactFormat({
|
||||
level: 'info',
|
||||
message: 'visible',
|
||||
[SPLAT_SYMBOL]: [metadata],
|
||||
});
|
||||
const splat = info[SPLAT_SYMBOL] as Array<typeof metadata>;
|
||||
|
||||
expect(splat[0].apiKey).toBe('[REDACTED]');
|
||||
expect(splat[0].authorization).toBe('[REDACTED]');
|
||||
expect(splat[0].nested.token).toBe('[REDACTED]');
|
||||
expect(splat[0]['x-api-key']).toBe('[REDACTED]');
|
||||
expect(splat[0].safe).toBe('secretvalue');
|
||||
expect(metadata.apiKey).toBe('secretvalue');
|
||||
expect(metadata.nested.token).toBe('secretvalue');
|
||||
});
|
||||
|
||||
it('redacts object messages before serialization', () => {
|
||||
const message = {
|
||||
apiKey: 'secretvalue',
|
||||
nested: { url: 'https://example.test/?api_key=secretvalue&next=true' },
|
||||
};
|
||||
const info = runRedactFormat({
|
||||
level: 'debug',
|
||||
message,
|
||||
});
|
||||
|
||||
expect(info.message).toEqual({
|
||||
apiKey: '[REDACTED]',
|
||||
nested: { url: 'https://example.test/?api_key=[REDACTED]&next=true' },
|
||||
});
|
||||
expect(message.apiKey).toBe('secretvalue');
|
||||
expect(message.nested.url).toBe('https://example.test/?api_key=secretvalue&next=true');
|
||||
});
|
||||
|
||||
it('redacts serializable non-plain splat values before interpolation', () => {
|
||||
const info = runRedactSplatFormat({
|
||||
level: 'info',
|
||||
message: 'values %s %s',
|
||||
[SPLAT_SYMBOL]: [
|
||||
new URL('https://example.test/?api_key=secretvalue&next=true'),
|
||||
Buffer.from('sk-abc123def'),
|
||||
],
|
||||
});
|
||||
|
||||
expect(info.message).toBe(
|
||||
'values https://example.test/?api_key=[REDACTED]&next=true sk-[REDACTED]',
|
||||
);
|
||||
});
|
||||
|
||||
it('summarizes large buffer splats instead of scanning the whole payload', () => {
|
||||
const buffer = Buffer.concat([Buffer.alloc(9000, 'a'), Buffer.from('sk-abc123def')]);
|
||||
|
||||
const info = runRedactSplatFormat({
|
||||
level: 'info',
|
||||
message: 'payload %s',
|
||||
[SPLAT_SYMBOL]: [buffer],
|
||||
});
|
||||
|
||||
expect(info.message).toBe(`payload [REDACTED Buffer ${buffer.length} bytes]`);
|
||||
expect(info.message).not.toContain('sk-abc123def');
|
||||
});
|
||||
|
||||
it('bounds large object and array redaction work', () => {
|
||||
const metadata: Record<string, unknown> = {};
|
||||
Array.from({ length: 60 }).forEach((_, index) => {
|
||||
metadata[`field${index}`] = index === 59 ? 'sk-abc123def' : `safe-${index}`;
|
||||
});
|
||||
|
||||
const info = runRedactFormat({
|
||||
level: 'info',
|
||||
message: 'visible',
|
||||
[SPLAT_SYMBOL]: [[metadata, ...Array.from({ length: 60 }, (_, index) => `safe-${index}`)]],
|
||||
});
|
||||
const splat = info[SPLAT_SYMBOL] as Array<Array<Record<string, unknown> | string>>;
|
||||
const redactedObject = splat[0][0] as Record<string, unknown>;
|
||||
|
||||
expect(Object.keys(redactedObject)).toHaveLength(51);
|
||||
expect(redactedObject.field49).toBe('safe-49');
|
||||
expect(redactedObject.field59).toBeUndefined();
|
||||
expect(redactedObject.__redaction_truncated__).toBe('Additional object properties omitted');
|
||||
expect(splat[0]).toHaveLength(51);
|
||||
expect(splat[0][50]).toBe('Additional array values omitted');
|
||||
expect(JSON.stringify(splat)).not.toContain('sk-abc123def');
|
||||
});
|
||||
|
||||
it('redacts over-deep object subtrees instead of traversing indefinitely', () => {
|
||||
const root: Record<string, unknown> = {};
|
||||
let current = root;
|
||||
Array.from({ length: 12 }).forEach((_, index) => {
|
||||
current.child = { label: `level-${index}` };
|
||||
current = current.child as Record<string, unknown>;
|
||||
});
|
||||
current.apiKey = 'secretvalue';
|
||||
|
||||
const info = runRedactFormat({
|
||||
level: 'info',
|
||||
message: root,
|
||||
});
|
||||
|
||||
const serialized = JSON.stringify(info.message);
|
||||
expect(serialized).toContain('[REDACTED]');
|
||||
expect(serialized).not.toContain('secretvalue');
|
||||
});
|
||||
|
||||
it('redacts error splat arguments without mutating the original error', () => {
|
||||
const error = new Error('Bearer secretvalue');
|
||||
error.stack = 'Error: Bearer secretvalue\n at request';
|
||||
|
||||
const info = runRedactFormat({
|
||||
level: 'warn',
|
||||
message: 'request failed',
|
||||
[SPLAT_SYMBOL]: [error],
|
||||
});
|
||||
const splat = info[SPLAT_SYMBOL] as Error[];
|
||||
|
||||
expect(splat[0]).toBeInstanceOf(Error);
|
||||
expect(splat[0]).not.toBe(error);
|
||||
expect(splat[0].message).toBe('Bearer [REDACTED]');
|
||||
expect(splat[0].stack).toContain('Bearer [REDACTED]');
|
||||
expect(Object.prototype.propertyIsEnumerable.call(splat[0], 'message')).toBe(false);
|
||||
expect(Object.prototype.propertyIsEnumerable.call(splat[0], 'stack')).toBe(false);
|
||||
expect(error.message).toBe('Bearer secretvalue');
|
||||
expect(error.stack).toContain('Bearer secretvalue');
|
||||
});
|
||||
|
||||
it('preserves contextual messages when redacting error splat arguments', () => {
|
||||
const error = new Error('Bearer secretvalue');
|
||||
|
||||
const info = runRedactSplatFormat({
|
||||
level: 'warn',
|
||||
message: 'request failed',
|
||||
[SPLAT_SYMBOL]: [error],
|
||||
});
|
||||
|
||||
expect(info.message).toBe('request failed');
|
||||
});
|
||||
|
||||
it('preserves and redacts non-enumerable error causes when cloning errors', () => {
|
||||
const cause = new Error('Bearer cause-secret');
|
||||
const error = new Error('outer');
|
||||
Object.defineProperty(error, 'cause', {
|
||||
value: cause,
|
||||
writable: true,
|
||||
enumerable: false,
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
const info = runRedactFormat({
|
||||
level: 'warn',
|
||||
message: 'request failed',
|
||||
[SPLAT_SYMBOL]: [error],
|
||||
});
|
||||
const splat = info[SPLAT_SYMBOL] as Array<Error & { cause?: Error }>;
|
||||
|
||||
expect(splat[0].cause).toBeInstanceOf(Error);
|
||||
expect(splat[0].cause).not.toBe(cause);
|
||||
expect(splat[0].cause?.message).toBe('Bearer [REDACTED]');
|
||||
expect(Object.prototype.propertyIsEnumerable.call(splat[0], 'cause')).toBe(false);
|
||||
expect(cause.message).toBe('Bearer cause-secret');
|
||||
});
|
||||
});
|
||||
|
||||
describe('debugTraverse request context', () => {
|
||||
it('appends request context metadata for non-debug lines', () => {
|
||||
const out = runFormatter(
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
import { klona } from 'klona';
|
||||
import winston from 'winston';
|
||||
import traverse from '../utils/object-traverse';
|
||||
import { SYSTEM_TENANT_ID } from './tenantContext';
|
||||
import type { TraverseContext } from '../utils/object-traverse';
|
||||
import { SYSTEM_TENANT_ID } from './tenantContext';
|
||||
import traverse from '../utils/object-traverse';
|
||||
|
||||
const SPLAT_SYMBOL = Symbol.for('splat');
|
||||
const MESSAGE_SYMBOL = Symbol.for('message');
|
||||
|
|
@ -10,27 +10,28 @@ const CONSOLE_JSON_STRING_LENGTH: number =
|
|||
parseInt(process.env.CONSOLE_JSON_STRING_LENGTH || '', 10) || 255;
|
||||
const DEBUG_MESSAGE_LENGTH: number = parseInt(process.env.DEBUG_MESSAGE_LENGTH || '', 10) || 150;
|
||||
const LOG_CONTEXT_KEYS = ['tenantId', 'userId', 'requestId'] as const;
|
||||
const REDACTED_VALUE = '[REDACTED]';
|
||||
const REDACTION_TRUNCATED_KEY = '__redaction_truncated__';
|
||||
const MAX_REDACTION_DEPTH = 8;
|
||||
const MAX_REDACTION_ENTRIES = 50;
|
||||
const DEFAULT_REDACTION_STRING_LENGTH = 8192;
|
||||
const MAX_REDACTION_STRING_LENGTH = Math.max(
|
||||
CONSOLE_JSON_STRING_LENGTH,
|
||||
DEFAULT_REDACTION_STRING_LENGTH,
|
||||
);
|
||||
const MAX_REDACTION_BUFFER_BYTES = MAX_REDACTION_STRING_LENGTH;
|
||||
|
||||
const sensitiveKeys: RegExp[] = [
|
||||
/^(sk-)[^\s]+/, // OpenAI API key pattern
|
||||
/(Bearer )[^\s]+/, // Header: Bearer token pattern
|
||||
/(api-key:? )[^\s]+/, // Header: API key pattern
|
||||
/(key=)[^\s]+/, // URL query param: sensitive key pattern (Google)
|
||||
/\b(sk-)[a-zA-Z0-9_-]+/g, // OpenAI API key pattern
|
||||
/\b(Bearer )[^\s"']+/g, // Header: Bearer token pattern
|
||||
/\b(api-key:? )[^\s"']+/gi, // Header: API key pattern
|
||||
/\b(api_key=)[^\s"'&]+/gi, // URL query param: API key pattern
|
||||
/\b(key=)[^\s"'&]+/g, // URL query param: sensitive key pattern
|
||||
];
|
||||
|
||||
/**
|
||||
* Determines if a given value string is sensitive and returns matching regex patterns.
|
||||
*
|
||||
* @param valueStr - The value string to check.
|
||||
* @returns An array of regex patterns that match the value string.
|
||||
*/
|
||||
function getMatchingSensitivePatterns(valueStr: string): RegExp[] {
|
||||
if (valueStr) {
|
||||
// Filter and return all regex patterns that match the value string
|
||||
return sensitiveKeys.filter((regex) => regex.test(valueStr));
|
||||
}
|
||||
return [];
|
||||
}
|
||||
const sensitiveMetadataKey =
|
||||
/^(authorization|proxy-authorization|x-api-key|api[-_]?key|access[-_]?token|refresh[-_]?token|id[-_]?token|token|secret|password)$/i;
|
||||
const errorStringProperties = new Set(['name', 'message', 'stack']);
|
||||
|
||||
/**
|
||||
* Redacts sensitive information from a console message and trims it to a specified length if provided.
|
||||
|
|
@ -43,36 +44,306 @@ function redactMessage(str: string, trimLength?: number): string {
|
|||
return '';
|
||||
}
|
||||
|
||||
const patterns = getMatchingSensitivePatterns(str);
|
||||
patterns.forEach((pattern) => {
|
||||
str = str.replace(pattern, '$1[REDACTED]');
|
||||
});
|
||||
const redacted = sensitiveKeys.reduce(
|
||||
(currentMessage, pattern) => currentMessage.replace(pattern, '$1[REDACTED]'),
|
||||
str,
|
||||
);
|
||||
|
||||
if (trimLength !== undefined && str.length > trimLength) {
|
||||
return `${str.substring(0, trimLength)}...`;
|
||||
if (trimLength !== undefined && redacted.length > trimLength) {
|
||||
return `${redacted.substring(0, trimLength)}...`;
|
||||
}
|
||||
|
||||
return str;
|
||||
return redacted;
|
||||
}
|
||||
|
||||
function redactLogString(str: string): string {
|
||||
if (str.length <= MAX_REDACTION_STRING_LENGTH) {
|
||||
return redactMessage(str);
|
||||
}
|
||||
|
||||
const redacted = redactMessage(str.substring(0, MAX_REDACTION_STRING_LENGTH));
|
||||
return `${redacted}... [truncated ${str.length - MAX_REDACTION_STRING_LENGTH} chars]`;
|
||||
}
|
||||
|
||||
function isPlainRecord(value: object): value is Record<string, unknown> {
|
||||
const prototype = Object.getPrototypeOf(value);
|
||||
return prototype === Object.prototype || prototype === null;
|
||||
}
|
||||
|
||||
function isSensitiveMetadataKey(key: string): boolean {
|
||||
return sensitiveMetadataKey.test(key);
|
||||
}
|
||||
|
||||
function redactRecordValue(
|
||||
key: string,
|
||||
value: unknown,
|
||||
seen: WeakMap<object, unknown>,
|
||||
depth: number,
|
||||
): unknown {
|
||||
return isSensitiveMetadataKey(key) ? REDACTED_VALUE : redactLogValue(value, seen, depth);
|
||||
}
|
||||
|
||||
function defineRedactedErrorProperty(
|
||||
error: Error & Record<string, unknown>,
|
||||
key: 'name' | 'message' | 'stack',
|
||||
value: string | undefined,
|
||||
): void {
|
||||
if (value === undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
Object.defineProperty(error, key, {
|
||||
value: redactLogString(value),
|
||||
writable: true,
|
||||
enumerable: false,
|
||||
configurable: true,
|
||||
});
|
||||
}
|
||||
|
||||
function defineRedactedDescriptor(
|
||||
target: Error & Record<string, unknown>,
|
||||
key: string | symbol,
|
||||
descriptor: PropertyDescriptor,
|
||||
seen: WeakMap<object, unknown>,
|
||||
depth: number,
|
||||
): void {
|
||||
if (!('value' in descriptor)) {
|
||||
Object.defineProperty(target, key, descriptor);
|
||||
return;
|
||||
}
|
||||
|
||||
Object.defineProperty(target, key, {
|
||||
...descriptor,
|
||||
value:
|
||||
typeof key === 'string'
|
||||
? redactRecordValue(key, descriptor.value, seen, depth)
|
||||
: redactLogValue(descriptor.value, seen, depth),
|
||||
});
|
||||
}
|
||||
|
||||
function redactErrorValue(error: Error, seen: WeakMap<object, unknown>, depth: number): Error {
|
||||
const redacted = Object.create(Object.getPrototypeOf(error)) as Error & Record<string, unknown>;
|
||||
seen.set(error, redacted);
|
||||
|
||||
defineRedactedErrorProperty(redacted, 'name', error.name);
|
||||
defineRedactedErrorProperty(redacted, 'message', error.message);
|
||||
defineRedactedErrorProperty(redacted, 'stack', error.stack);
|
||||
|
||||
Reflect.ownKeys(error).forEach((key) => {
|
||||
if (typeof key === 'string' && errorStringProperties.has(key)) {
|
||||
return;
|
||||
}
|
||||
const descriptor = Object.getOwnPropertyDescriptor(error, key);
|
||||
if (descriptor === undefined) {
|
||||
return;
|
||||
}
|
||||
defineRedactedDescriptor(redacted, key, descriptor, seen, depth + 1);
|
||||
});
|
||||
return redacted;
|
||||
}
|
||||
|
||||
function isBufferValue(value: object): value is Buffer {
|
||||
return typeof Buffer !== 'undefined' && Buffer.isBuffer(value);
|
||||
}
|
||||
|
||||
function getJsonValue(value: object): unknown {
|
||||
const toJSON = (value as { toJSON?: unknown }).toJSON;
|
||||
if (typeof toJSON !== 'function') {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
try {
|
||||
const jsonValue = toJSON.call(value);
|
||||
return jsonValue === value ? undefined : jsonValue;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function getCustomStringValue(value: object): string | undefined {
|
||||
const toString = (value as { toString?: unknown }).toString;
|
||||
if (typeof toString !== 'function' || toString === Object.prototype.toString) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
try {
|
||||
const stringValue = toString.call(value);
|
||||
return typeof stringValue === 'string' ? stringValue : undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function redactMapValue(
|
||||
value: Map<unknown, unknown>,
|
||||
seen: WeakMap<object, unknown>,
|
||||
depth: number,
|
||||
): Map<unknown, unknown> {
|
||||
const redacted = new Map<unknown, unknown>();
|
||||
seen.set(value, redacted);
|
||||
let count = 0;
|
||||
for (const [mapKey, mapValue] of value) {
|
||||
if (count >= MAX_REDACTION_ENTRIES) {
|
||||
redacted.set(REDACTION_TRUNCATED_KEY, 'Additional map entries omitted');
|
||||
break;
|
||||
}
|
||||
redacted.set(
|
||||
mapKey,
|
||||
typeof mapKey === 'string'
|
||||
? redactRecordValue(mapKey, mapValue, seen, depth + 1)
|
||||
: redactLogValue(mapValue, seen, depth + 1),
|
||||
);
|
||||
count += 1;
|
||||
}
|
||||
return redacted;
|
||||
}
|
||||
|
||||
function redactSetValue(
|
||||
value: Set<unknown>,
|
||||
seen: WeakMap<object, unknown>,
|
||||
depth: number,
|
||||
): Set<unknown> {
|
||||
const redacted = new Set<unknown>();
|
||||
seen.set(value, redacted);
|
||||
let count = 0;
|
||||
for (const setValue of value) {
|
||||
if (count >= MAX_REDACTION_ENTRIES) {
|
||||
redacted.add('Additional set values omitted');
|
||||
break;
|
||||
}
|
||||
redacted.add(redactLogValue(setValue, seen, depth + 1));
|
||||
count += 1;
|
||||
}
|
||||
return redacted;
|
||||
}
|
||||
|
||||
function redactObjectEntries(
|
||||
value: object,
|
||||
seen: WeakMap<object, unknown>,
|
||||
depth: number,
|
||||
): Record<string, unknown> | undefined {
|
||||
const record = value as Record<string, unknown>;
|
||||
let redacted: Record<string, unknown> | undefined;
|
||||
let count = 0;
|
||||
|
||||
for (const key in record) {
|
||||
if (!Object.prototype.hasOwnProperty.call(record, key)) {
|
||||
continue;
|
||||
}
|
||||
if (redacted === undefined) {
|
||||
redacted = {};
|
||||
seen.set(value, redacted);
|
||||
}
|
||||
if (count >= MAX_REDACTION_ENTRIES) {
|
||||
redacted[REDACTION_TRUNCATED_KEY] = 'Additional object properties omitted';
|
||||
break;
|
||||
}
|
||||
redacted[key] = redactRecordValue(key, record[key], seen, depth + 1);
|
||||
count += 1;
|
||||
}
|
||||
|
||||
return redacted;
|
||||
}
|
||||
|
||||
function redactNonPlainValue(
|
||||
value: object,
|
||||
seen: WeakMap<object, unknown>,
|
||||
depth: number,
|
||||
): unknown {
|
||||
if (isBufferValue(value)) {
|
||||
return value.length > MAX_REDACTION_BUFFER_BYTES
|
||||
? `[REDACTED Buffer ${value.length} bytes]`
|
||||
: redactLogString(value.toString('utf8'));
|
||||
}
|
||||
|
||||
if (value instanceof URL || value instanceof URLSearchParams) {
|
||||
return redactLogString(value.toString());
|
||||
}
|
||||
|
||||
if (value instanceof Map) {
|
||||
return redactMapValue(value, seen, depth);
|
||||
}
|
||||
|
||||
if (value instanceof Set) {
|
||||
return redactSetValue(value, seen, depth);
|
||||
}
|
||||
|
||||
const jsonValue = getJsonValue(value);
|
||||
if (jsonValue !== undefined) {
|
||||
return redactLogValue(jsonValue, seen, depth + 1);
|
||||
}
|
||||
|
||||
const redactedEntries = redactObjectEntries(value, seen, depth);
|
||||
if (redactedEntries !== undefined) {
|
||||
return redactedEntries;
|
||||
}
|
||||
|
||||
const stringValue = getCustomStringValue(value);
|
||||
return stringValue !== undefined ? redactLogString(stringValue) : value;
|
||||
}
|
||||
|
||||
function redactLogValue(value: unknown, seen = new WeakMap<object, unknown>(), depth = 0): unknown {
|
||||
if (typeof value === 'string') {
|
||||
return redactLogString(value);
|
||||
}
|
||||
|
||||
if (value === null || typeof value !== 'object') {
|
||||
return value;
|
||||
}
|
||||
|
||||
const cached = seen.get(value);
|
||||
if (cached !== undefined) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
if (depth >= MAX_REDACTION_DEPTH) {
|
||||
return REDACTED_VALUE;
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
const redacted: unknown[] = [];
|
||||
seen.set(value, redacted);
|
||||
const length = Math.min(value.length, MAX_REDACTION_ENTRIES);
|
||||
for (let index = 0; index < length; index++) {
|
||||
redacted.push(redactLogValue(value[index], seen, depth + 1));
|
||||
}
|
||||
if (value.length > MAX_REDACTION_ENTRIES) {
|
||||
redacted.push('Additional array values omitted');
|
||||
}
|
||||
return redacted;
|
||||
}
|
||||
|
||||
if (value instanceof Error) {
|
||||
return redactErrorValue(value, seen, depth);
|
||||
}
|
||||
|
||||
if (!isPlainRecord(value)) {
|
||||
return redactNonPlainValue(value, seen, depth);
|
||||
}
|
||||
|
||||
return redactObjectEntries(value, seen, depth) ?? {};
|
||||
}
|
||||
|
||||
/**
|
||||
* Redacts sensitive information from log messages if the log level is 'error'.
|
||||
* Redacts sensitive information from log messages at every level.
|
||||
* Note: Intentionally mutates the object.
|
||||
* @param info - The log information object.
|
||||
* @returns The modified log information object.
|
||||
*/
|
||||
const redactFormat = winston.format((info: winston.Logform.TransformableInfo) => {
|
||||
if (info.level === 'error') {
|
||||
// Type guard to ensure message is a string
|
||||
if (typeof info.message === 'string') {
|
||||
info.message = redactMessage(info.message);
|
||||
}
|
||||
const infoRecord = info as Record<string | symbol, unknown>;
|
||||
|
||||
// Handle MESSAGE_SYMBOL with type safety
|
||||
const symbolValue = (info as Record<string | symbol, unknown>)[MESSAGE_SYMBOL];
|
||||
if (typeof symbolValue === 'string') {
|
||||
(info as Record<string | symbol, unknown>)[MESSAGE_SYMBOL] = redactMessage(symbolValue);
|
||||
}
|
||||
if (info.message !== undefined) {
|
||||
info.message = redactLogValue(info.message);
|
||||
}
|
||||
|
||||
const symbolValue = infoRecord[MESSAGE_SYMBOL];
|
||||
if (symbolValue !== undefined) {
|
||||
infoRecord[MESSAGE_SYMBOL] = redactLogValue(symbolValue);
|
||||
}
|
||||
|
||||
if (infoRecord[SPLAT_SYMBOL] !== undefined) {
|
||||
infoRecord[SPLAT_SYMBOL] = redactLogValue(infoRecord[SPLAT_SYMBOL]);
|
||||
}
|
||||
return info;
|
||||
});
|
||||
|
|
@ -129,7 +400,7 @@ function appendRequestContext(line: string, metadata: Record<string, unknown>):
|
|||
* Formats log messages for debugging purposes.
|
||||
* - Truncates long strings within log messages.
|
||||
* - Condenses arrays by truncating long strings and objects as strings within array items.
|
||||
* - Redacts sensitive information from log messages if the log level is 'error'.
|
||||
* - Message redaction is applied by redactFormat before this formatter.
|
||||
* - Converts log information object to a formatted string.
|
||||
*
|
||||
* @param options - The options for formatting log messages.
|
||||
|
|
|
|||
|
|
@ -3,9 +3,12 @@ import { v4 as uuidv4 } from 'uuid';
|
|||
import { RetentionMode } from 'librechat-data-provider';
|
||||
import { MongoMemoryServer } from 'mongodb-memory-server';
|
||||
import type { IMessage } from '..';
|
||||
import { createMessageMethods } from './message';
|
||||
import { tenantStorage, runAsSystem } from '~/config/tenantContext';
|
||||
import { createMessageMethods } from './message';
|
||||
import { createModels } from '../models';
|
||||
import logger from '~/config/winston';
|
||||
|
||||
const waitForTimestampTick = () => new Promise((resolve) => setTimeout(resolve, 2));
|
||||
|
||||
jest.mock('~/config/winston', () => ({
|
||||
error: jest.fn(),
|
||||
|
|
@ -65,6 +68,8 @@ describe('Message Operations', () => {
|
|||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
jest.clearAllMocks();
|
||||
|
||||
// Clear database
|
||||
await Message.deleteMany({});
|
||||
|
||||
|
|
@ -107,6 +112,18 @@ describe('Message Operations', () => {
|
|||
const result = await saveMessage(mockCtx, mockMessageData);
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should not log message params for invalid conversation IDs', async () => {
|
||||
mockMessageData.conversationId = 'invalid-id';
|
||||
mockMessageData.text = 'Sensitive prompt text';
|
||||
|
||||
await saveMessage(mockCtx, mockMessageData, { context: 'message-save-test' });
|
||||
|
||||
expect(logger.warn).toHaveBeenCalledWith(
|
||||
'Invalid conversation ID: invalid-id (context: message-save-test)',
|
||||
);
|
||||
expect(logger.info).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateMessageText', () => {
|
||||
|
|
@ -167,6 +184,8 @@ describe('Message Operations', () => {
|
|||
user: 'user123',
|
||||
});
|
||||
|
||||
await waitForTimestampTick();
|
||||
|
||||
await saveMessage(mockCtx, {
|
||||
messageId: 'msg3',
|
||||
conversationId,
|
||||
|
|
|
|||
|
|
@ -1,10 +1,10 @@
|
|||
import type { DeleteResult, FilterQuery, Model } from 'mongoose';
|
||||
import { RetentionMode } from 'librechat-data-provider';
|
||||
import logger from '~/config/winston';
|
||||
import type { DeleteResult, FilterQuery, Model } from 'mongoose';
|
||||
import type { AppConfig, IMessage } from '~/types';
|
||||
import { createTempChatExpirationDate } from '~/utils/tempChatRetention';
|
||||
import { createFallbackRetentionDate } from '~/utils/retention';
|
||||
import { tenantSafeBulkWrite } from '~/utils/tenantBulkWrite';
|
||||
import type { AppConfig, IMessage } from '~/types';
|
||||
import logger from '~/config/winston';
|
||||
|
||||
/** Simple UUID v4 regex to replace zod validation */
|
||||
const UUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||
|
|
@ -79,9 +79,9 @@ export function createMessageMethods(mongoose: typeof import('mongoose')): Messa
|
|||
|
||||
const conversationId = params.conversationId as string | undefined;
|
||||
if (!conversationId || !UUID_REGEX.test(conversationId)) {
|
||||
logger.warn(`Invalid conversation ID: ${conversationId}`);
|
||||
logger.info(`---\`saveMessage\` context: ${metadata?.context}`);
|
||||
logger.info(`---Invalid conversation ID Params: ${JSON.stringify(params, null, 2)}`);
|
||||
logger.warn(
|
||||
`Invalid conversation ID: ${conversationId} (context: ${metadata?.context ?? 'n/a'})`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue