From e941a15275ebccad318904e8d6f0ea539c5ba16c Mon Sep 17 00:00:00 2001 From: Lucas Santana Date: Wed, 10 Jun 2026 11:35:55 -0300 Subject: [PATCH] fix(backend): bound pagination limit on leaderboard and entries MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit levels/leaderboard and starboard/entries accepted unbounded limit query values (Number(req.query.limit) || 10 — negative and garbage values reached the service layer; only an upper Math.min cap existed). Adds validateQuery with int 1..50 bounds so out-of-range returns 400 before any DB query, matching trackHistory's existing pattern (already guarded by historyQuery/topQuery — left unchanged). Tests: 8 bound cases in snowflakeValidation.test.ts (limit=9999/0/-5/ abc -> 400; in-range passes). Backend 997/997. Closes #1183 --- packages/backend/src/routes/levels.ts | 11 ++++- packages/backend/src/routes/starboard.ts | 11 ++++- .../unit/routes/snowflakeValidation.test.ts | 44 +++++++++++++++++++ 3 files changed, 64 insertions(+), 2 deletions(-) diff --git a/packages/backend/src/routes/levels.ts b/packages/backend/src/routes/levels.ts index 0c55e9e32..e1c94b4d0 100644 --- a/packages/backend/src/routes/levels.ts +++ b/packages/backend/src/routes/levels.ts @@ -1,6 +1,10 @@ import type { Express, Response } from 'express' import { requireAuth, type AuthenticatedRequest } from '../middleware/auth' -import { validateBody, validateParams } from '../middleware/validate' +import { + validateBody, + validateParams, + validateQuery, +} from '../middleware/validate' import { writeLimiter } from '../middleware/rateLimit' import { asyncHandler } from '../middleware/asyncHandler' import { AppError } from '../errors/AppError' @@ -15,6 +19,10 @@ function p(val: string | string[]): string { return typeof val === 'string' ? val : val[0] } +const leaderboardQuery = z.object({ + limit: z.coerce.number().int().min(1).max(50).optional(), +}) + const rankParams = guildIdParam.merge(commonUserIdParam) const levelParam = guildIdParam.extend({ level: z.coerce.number().int().min(1), @@ -62,6 +70,7 @@ export function setupLevelsRoutes(app: Express): void { '/api/guilds/:guildId/levels/leaderboard', requireAuth, validateParams(guildIdParam), + validateQuery(leaderboardQuery), asyncHandler(async (req: AuthenticatedRequest, res: Response) => { const guildId = p(req.params.guildId) const limit = Number(req.query.limit) || 10 diff --git a/packages/backend/src/routes/starboard.ts b/packages/backend/src/routes/starboard.ts index b4999927d..196b4f7ea 100644 --- a/packages/backend/src/routes/starboard.ts +++ b/packages/backend/src/routes/starboard.ts @@ -1,6 +1,10 @@ import type { Express, Response } from 'express' import { requireAuth, type AuthenticatedRequest } from '../middleware/auth' -import { validateBody, validateParams } from '../middleware/validate' +import { + validateBody, + validateParams, + validateQuery, +} from '../middleware/validate' import { writeLimiter } from '../middleware/rateLimit' import { asyncHandler } from '../middleware/asyncHandler' import { AppError } from '../errors/AppError' @@ -19,6 +23,10 @@ const upsertConfigBody = z.object({ selfStar: z.boolean().optional(), }) +const entriesQuery = z.object({ + limit: z.coerce.number().int().min(1).max(50).optional(), +}) + export function setupStarboardRoutes(app: Express): void { app.get( '/api/guilds/:guildId/starboard/config', @@ -77,6 +85,7 @@ export function setupStarboardRoutes(app: Express): void { '/api/guilds/:guildId/starboard/entries', requireAuth, validateParams(guildIdParam), + validateQuery(entriesQuery), asyncHandler(async (req: AuthenticatedRequest, res: Response) => { const guildId = p(req.params.guildId) const limit = Number(req.query.limit) || 10 diff --git a/packages/backend/tests/unit/routes/snowflakeValidation.test.ts b/packages/backend/tests/unit/routes/snowflakeValidation.test.ts index 9e58aaf52..f44d871ed 100644 --- a/packages/backend/tests/unit/routes/snowflakeValidation.test.ts +++ b/packages/backend/tests/unit/routes/snowflakeValidation.test.ts @@ -357,6 +357,50 @@ describe('Guild ID Snowflake Validation', () => { ) }) + describe('Pagination limit bounds (#1183)', () => { + const validGuildId = '123456789012345678' + + test.each([ + ['9999', 400], + ['0', 400], + ['-5', 400], + ['abc', 400], + ['25', 200], + ])( + 'GET levels/leaderboard with limit=%s returns %s-class', + async (limit, expected) => { + const app = createApp(setupLevelsRoutes) + const res = await request(app).get( + `/api/guilds/${validGuildId}/levels/leaderboard?limit=${limit}`, + ) + if (expected === 400) { + expect(res.status).toBe(400) + } else { + expect(res.status).not.toBe(400) + } + }, + ) + + test.each([ + ['9999', 400], + ['0', 400], + ['25', 200], + ])( + 'GET starboard/entries with limit=%s returns %s-class', + async (limit, expected) => { + const app = createApp(setupStarboardRoutes) + const res = await request(app).get( + `/api/guilds/${validGuildId}/starboard/entries?limit=${limit}`, + ) + if (expected === 400) { + expect(res.status).toBe(400) + } else { + expect(res.status).not.toBe(400) + } + }, + ) + }) + describe('Music Routes (#1200)', () => { const validGuildId = '123456789012345678' const invalidGuildId = 'not-a-snowflake'