🧼 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:
Danny Avila 2026-06-06 18:40:57 -04:00 committed by GitHub
parent 265d660076
commit 4b871a11ad
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 601 additions and 47 deletions

View file

@ -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' });

View file

@ -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();

View file

@ -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(

View file

@ -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.

View file

@ -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,

View file

@ -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;
}