diff --git a/api/server/routes/tags.js b/api/server/routes/tags.js index 10be2b2e8f..d4584a5212 100644 --- a/api/server/routes/tags.js +++ b/api/server/routes/tags.js @@ -98,7 +98,8 @@ router.put('/:tag', configMiddleware, async (req, res) => { const decodedTag = decodeURIComponent(req.params.tag); const tag = await updateConversationTag(req.user.id, decodedTag, req.body); if (tag) { - await enforceForcedRetentionForTag(req, req.body?.tag || decodedTag, 'PUT /api/tags/:tag'); + const renamedTag = typeof req.body?.tag === 'string' ? req.body.tag : decodedTag; + await enforceForcedRetentionForTag(req, renamedTag, 'PUT /api/tags/:tag'); res.status(200).json(tag); } else { res.status(404).json({ error: 'Tag not found' }); diff --git a/packages/data-schemas/src/methods/message.spec.ts b/packages/data-schemas/src/methods/message.spec.ts index f69b47a742..cda4ff692a 100644 --- a/packages/data-schemas/src/methods/message.spec.ts +++ b/packages/data-schemas/src/methods/message.spec.ts @@ -1244,6 +1244,29 @@ describe('Message Operations', () => { const convo = await Conversation().findOne({ conversationId }).lean(); expect(convo?.expiredAt ?? null).toBeNull(); }); + + it('ignores a non-string tag instead of matching every conversation (NoSQL injection)', async () => { + const conversationId = uuidv4(); + await Conversation().create({ + conversationId, + user: 'user123', + endpoint: 'openAI', + tags: ['work'], + }); + + await applyForcedRetentionToTag( + { + userId: 'user123', + interfaceConfig: { temporaryChatRetention: 24, retentionMode: RetentionMode.EPHEMERAL }, + }, + { tag: { $gt: '' } as unknown as string }, + { context: 'PUT /api/tags/:tag' }, + ); + + const convo = await Conversation().findOne({ conversationId }).lean(); + expect(convo?.isTemporary ?? null).not.toBe(true); + expect(convo?.expiredAt ?? null).toBeNull(); + }); }); describe('Message cursor pagination', () => { diff --git a/packages/data-schemas/src/methods/message.ts b/packages/data-schemas/src/methods/message.ts index 133b4cbcae..d40267e5f1 100644 --- a/packages/data-schemas/src/methods/message.ts +++ b/packages/data-schemas/src/methods/message.ts @@ -384,6 +384,12 @@ export function createMessageMethods(mongoose: typeof import('mongoose')): Messa if (!isForcedTemporaryRetention(interfaceConfig?.retentionMode)) { return; } + if (typeof conversationId !== 'string' || conversationId.length === 0) { + logger.warn( + `[applyForcedRetention] Ignoring non-string conversationId (context: ${metadata?.context ?? 'n/a'})`, + ); + return; + } let forcedExpiredAt: Date; try { @@ -409,7 +415,7 @@ export function createMessageMethods(mongoose: typeof import('mongoose')): Messa ); } - if (messageId) { + if (typeof messageId === 'string' && messageId.length > 0) { await Message.updateOne( { messageId, user: userId }, { $set: { isTemporary: true, expiredAt: forcedExpiredAt } }, @@ -440,6 +446,12 @@ export function createMessageMethods(mongoose: typeof import('mongoose')): Messa if (!isForcedTemporaryRetention(interfaceConfig?.retentionMode)) { return; } + if (typeof tag !== 'string' || tag.length === 0) { + logger.warn( + `[applyForcedRetentionToTag] Ignoring non-string tag (context: ${metadata?.context ?? 'n/a'})`, + ); + return; + } let forcedExpiredAt: Date; try {