From aae066dca69fe2302aa6cc12f59dec1f556b8403 Mon Sep 17 00:00:00 2001 From: Marco Beretta <81851188+berry-13@users.noreply.github.com> Date: Wed, 24 Jun 2026 15:43:46 +0200 Subject: [PATCH] fix: reject non-string tag and conversationId in forced-retention helpers The bookmark-tag and conversation ids passed to the forced-retention helpers come from untyped request bodies, so a crafted PUT /api/tags body like {"tag": {"$gt": ""}} reached Conversation.find({ tags }) as a query operator and matched every tagged conversation instead of one, bulk-converting them under ephemeral retention (NoSQL operator injection). The same applied to req.body.conversationId on POST. Guard applyForcedRetention and applyForcedRetentionToTag to ignore any non-string conversationId/messageId/tag, and pass a guaranteed string from the tag rename route. --- api/server/routes/tags.js | 3 ++- .../data-schemas/src/methods/message.spec.ts | 23 +++++++++++++++++++ packages/data-schemas/src/methods/message.ts | 14 ++++++++++- 3 files changed, 38 insertions(+), 2 deletions(-) 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 {