diff --git a/src/app/api/dht/browse/route.ts b/src/app/api/dht/browse/route.ts index 9db19015..51a0d156 100644 --- a/src/app/api/dht/browse/route.ts +++ b/src/app/api/dht/browse/route.ts @@ -8,6 +8,7 @@ import { NextRequest, NextResponse } from 'next/server'; import { getServerClient } from '@/lib/supabase/client'; +import { parseIntegerParam } from '@/lib/api/pagination'; const VALID_SORT_BY = ['seeders', 'leechers', 'size', 'date', 'name'] as const; type SortBy = typeof VALID_SORT_BY[number]; @@ -67,8 +68,8 @@ export async function GET(request: NextRequest): Promise { const limitParam = searchParams.get('limit'); let limit = DEFAULT_LIMIT; if (limitParam) { - const parsed = parseInt(limitParam, 10); - if (isNaN(parsed) || parsed < 1 || parsed > MAX_LIMIT) { + const parsed = parseIntegerParam(limitParam, { min: 1, max: MAX_LIMIT }); + if (parsed == null) { return NextResponse.json( { error: `Limit must be between 1 and ${MAX_LIMIT}` }, { status: 400 } @@ -81,8 +82,8 @@ export async function GET(request: NextRequest): Promise { const offsetParam = searchParams.get('offset'); let offset = 0; if (offsetParam) { - const parsed = parseInt(offsetParam, 10); - if (isNaN(parsed) || parsed < 0) { + const parsed = parseIntegerParam(offsetParam, { min: 0 }); + if (parsed == null) { return NextResponse.json( { error: 'Offset must be a non-negative integer' }, { status: 400 } diff --git a/src/app/api/search/files/route.ts b/src/app/api/search/files/route.ts index 7d6cdf89..c997aba2 100644 --- a/src/app/api/search/files/route.ts +++ b/src/app/api/search/files/route.ts @@ -8,6 +8,7 @@ import { NextRequest, NextResponse } from 'next/server'; import { searchTorrentFiles } from '@/lib/torrent-index'; +import { parseIntegerParam } from '@/lib/api/pagination'; import type { MediaCategory } from '@/lib/supabase/types'; // Valid media types @@ -59,18 +60,18 @@ export async function GET(request: NextRequest): Promise { } // Parse pagination parameters - const limit = limitParam ? parseInt(limitParam, 10) : 50; - const offset = offsetParam ? parseInt(offsetParam, 10) : 0; + const limit = limitParam ? parseIntegerParam(limitParam, { min: 0 }) : 50; + const offset = offsetParam ? parseIntegerParam(offsetParam, { min: 0 }) : 0; // Validate pagination - if (isNaN(limit) || limit < 0) { + if (limit == null) { return NextResponse.json( { error: 'Invalid limit parameter' }, { status: 400 } ); } - if (isNaN(offset) || offset < 0) { + if (offset == null) { return NextResponse.json( { error: 'Invalid offset parameter' }, { status: 400 } diff --git a/src/app/api/search/route.ts b/src/app/api/search/route.ts index 009b9e17..71f34b64 100644 --- a/src/app/api/search/route.ts +++ b/src/app/api/search/route.ts @@ -9,6 +9,7 @@ import { NextRequest, NextResponse } from 'next/server'; import { getServerClient } from '@/lib/supabase/client'; import { searchFiles } from '@/lib/supabase'; +import { parseIntegerParam } from '@/lib/api/pagination'; import type { MediaCategory } from '@/lib/supabase'; /** @@ -158,8 +159,8 @@ export async function GET(request: NextRequest): Promise { + it('rejects missing, partial, fractional, and unsafe values', () => { + expect(parseIntegerParam(null)).toBeNull(); + expect(parseIntegerParam('')).toBeNull(); + expect(parseIntegerParam('10abc')).toBeNull(); + expect(parseIntegerParam('1.5')).toBeNull(); + expect(parseIntegerParam('9007199254740992')).toBeNull(); + }); + + it('enforces optional bounds', () => { + expect(parseIntegerParam('10', { min: 1, max: 100 })).toBe(10); + expect(parseIntegerParam('0', { min: 1, max: 100 })).toBeNull(); + expect(parseIntegerParam('101', { min: 1, max: 100 })).toBeNull(); + }); + + it('trims valid integer input', () => { + expect(parseIntegerParam(' 25 ', { min: 0 })).toBe(25); + }); +}); diff --git a/src/lib/api/pagination.ts b/src/lib/api/pagination.ts new file mode 100644 index 00000000..73fccc6b --- /dev/null +++ b/src/lib/api/pagination.ts @@ -0,0 +1,24 @@ +export function parseIntegerParam( + value: string | null, + options: { min?: number; max?: number } = {} +): number | null { + const raw = value?.trim(); + if (!raw || !/^\d+$/.test(raw)) { + return null; + } + + const parsed = Number(raw); + if (!Number.isSafeInteger(parsed)) { + return null; + } + + if (options.min != null && parsed < options.min) { + return null; + } + + if (options.max != null && parsed > options.max) { + return null; + } + + return parsed; +}