diff --git a/api/server/routes/__tests__/share.spec.js b/api/server/routes/__tests__/share.spec.js index 02941c8453..e26dd9be6c 100644 --- a/api/server/routes/__tests__/share.spec.js +++ b/api/server/routes/__tests__/share.spec.js @@ -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' }); diff --git a/api/server/routes/share.js b/api/server/routes/share.js index 19c760007a..03cc00fbc2 100644 --- a/api/server/routes/share.js +++ b/api/server/routes/share.js @@ -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(); diff --git a/packages/data-schemas/src/config/parsers.spec.ts b/packages/data-schemas/src/config/parsers.spec.ts index 188f42db3c..b008c0aeb9 100644 --- a/packages/data-schemas/src/config/parsers.spec.ts +++ b/packages/data-schemas/src/config/parsers.spec.ts @@ -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 & { timestamp: string; }; +type RedactInfo = Record & { + 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): 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; + + 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 = {}; + 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 | string>>; + const redactedObject = splat[0][0] as Record; + + 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 = {}; + let current = root; + Array.from({ length: 12 }).forEach((_, index) => { + current.child = { label: `level-${index}` }; + current = current.child as Record; + }); + 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; + + 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( diff --git a/packages/data-schemas/src/config/parsers.ts b/packages/data-schemas/src/config/parsers.ts index e21927a72b..0558759474 100644 --- a/packages/data-schemas/src/config/parsers.ts +++ b/packages/data-schemas/src/config/parsers.ts @@ -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 { + 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, + depth: number, +): unknown { + return isSensitiveMetadataKey(key) ? REDACTED_VALUE : redactLogValue(value, seen, depth); +} + +function defineRedactedErrorProperty( + error: Error & Record, + 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, + key: string | symbol, + descriptor: PropertyDescriptor, + seen: WeakMap, + 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, depth: number): Error { + const redacted = Object.create(Object.getPrototypeOf(error)) as Error & Record; + 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, + seen: WeakMap, + depth: number, +): Map { + const redacted = new Map(); + 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, + seen: WeakMap, + depth: number, +): Set { + const redacted = new Set(); + 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, + depth: number, +): Record | undefined { + const record = value as Record; + let redacted: Record | 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, + 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(), 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; - // Handle MESSAGE_SYMBOL with type safety - const symbolValue = (info as Record)[MESSAGE_SYMBOL]; - if (typeof symbolValue === 'string') { - (info as Record)[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): * 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. diff --git a/packages/data-schemas/src/methods/message.spec.ts b/packages/data-schemas/src/methods/message.spec.ts index 98b02dd19d..3c3986de93 100644 --- a/packages/data-schemas/src/methods/message.spec.ts +++ b/packages/data-schemas/src/methods/message.spec.ts @@ -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, diff --git a/packages/data-schemas/src/methods/message.ts b/packages/data-schemas/src/methods/message.ts index acaff7c778..dc3ab6d5b2 100644 --- a/packages/data-schemas/src/methods/message.ts +++ b/packages/data-schemas/src/methods/message.ts @@ -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; }