🗜️ fix: Bound Conversation Page Size (#15576)

`GET /api/convos` read its page size as `parseInt(req.query.limit, 10) || 25`
and handed it to `getConvosByCursor` unclamped, where it becomes
`.limit(limit + 1)`. `?limit=-1` therefore issued `.limit(0)`, which MongoDB
reads as no limit at all, and the route returned the caller's entire
conversation list in one response; `?limit=999999999` forced the same unbounded
fetch. `attachSharedFlags` then amplified it with a second query whose `$in`
carried every returned id.

Every other list route already clamps, so the fix reuses that shape: the
projects handler's local `normalizeLimit` moves to `packages/api/src/utils/query`
and both routes share it, and `getConvosByCursor` bounds its own page size so
the invariant holds for any caller.
This commit is contained in:
Danny Avila 2026-09-03 23:52:36 -04:00 • committed by GitHub
parent 374ed8df14
commit 78b320cbbc
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 246 additions and 22 deletions

View file

@ -36,6 +36,15 @@ module.exports = {
}
return false;
}),
/** Mirrors the real helper so the route's page-size clamping is exercised. */
normalizeLimit: jest.fn((value, { fallback = 25, max = 100 } = {}) => {
const raw = Array.isArray(value) ? value[0] : value;
const limit = parseInt(typeof raw === 'string' ? raw : '', 10);
if (!Number.isFinite(limit)) {
return fallback;
}
return Math.min(Math.max(limit, 1), max);
}),
resolveImportMaxFileSize: jest.fn(() => 262144000),
createAxiosInstance: jest.fn(() => ({
get: jest.fn(),

View file

@ -1417,6 +1417,73 @@ describe('Convos Routes', () => {
});
});
describe('GET / limit clamping', () => {
const { getConvosByCursor } = require('~/models');
beforeEach(() => {
getConvosByCursor.mockResolvedValue({ conversations: [], nextCursor: null });
});
/** `parseInt('-1') || 25` kept the -1, and `.limit(-1 + 1)` is `.limit(0)`, which
* MongoDB reads as "no limit" — one request drained the caller's whole list. */
it('clamps a negative limit instead of forwarding it', async () => {
const response = await request(app).get('/api/convos').query({ limit: '-1' });
expect(response.status).toBe(200);
expect(getConvosByCursor).toHaveBeenCalledWith(
'test-user-123',
expect.objectContaining({ limit: 1 }),
);
});
it('caps an oversized limit at the page ceiling', async () => {
const response = await request(app).get('/api/convos').query({ limit: '999999999' });
expect(response.status).toBe(200);
expect(getConvosByCursor).toHaveBeenCalledWith(
'test-user-123',
expect.objectContaining({ limit: 100 }),
);
});
it('raises a zero limit to a single row', async () => {
const response = await request(app).get('/api/convos').query({ limit: '0' });
expect(response.status).toBe(200);
expect(getConvosByCursor).toHaveBeenCalledWith(
'test-user-123',
expect.objectContaining({ limit: 1 }),
);
});
it.each([
['absent', undefined],
['non-numeric', 'all'],
])('falls back to the default page size when the limit is %s', async (_label, limit) => {
const response = await request(app)
.get('/api/convos')
.query(limit === undefined ? {} : { limit });
expect(response.status).toBe(200);
expect(getConvosByCursor).toHaveBeenCalledWith(
'test-user-123',
expect.objectContaining({ limit: 25 }),
);
});
/** Express parses a repeated key into an array, which `parseInt` silently reads as
* its first element's digits rather than rejecting. */
it('clamps a repeated limit parameter', async () => {
const response = await request(app).get('/api/convos?limit=999999&limit=5');
expect(response.status).toBe(200);
expect(getConvosByCursor).toHaveBeenCalledWith(
'test-user-123',
expect.objectContaining({ limit: 100 }),
);
});
});
describe('POST /archive', () => {
it('should archive a conversation successfully', async () => {
const mockConversationId = 'conv-123';

View file

@ -3,6 +3,7 @@ const express = require('express');
const { sleep } = require('@librechat/agents');
const {
isEnabled,
normalizeLimit,
deleteAgentCheckpoints,
createArchiveAllHandler,
createSubagentActivityStreamHandler,
@ -141,7 +142,7 @@ const isValidProjectFilter = (projectId) =>
!projectId || projectId === 'unassigned' || /^[a-f\d]{24}$/i.test(projectId);
router.get('/', async (req, res) => {
const limit = parseInt(req.query.limit, 10) || 25;
const limit = normalizeLimit(req.query.limit);
const cursor = req.query.cursor;
const isArchived = isEnabled(req.query.isArchived);
const pinned = isEnabled(req.query.pinned);