diff --git a/src/app/api/affiliates/offers/route.test.ts b/src/app/api/affiliates/offers/route.test.ts index 25b9adc2..49675175 100644 --- a/src/app/api/affiliates/offers/route.test.ts +++ b/src/app/api/affiliates/offers/route.test.ts @@ -40,6 +40,26 @@ function chainable(data: unknown, error: unknown = null, count: number | null = return new Proxy(result, handler); } +function offerListChain(rangeSpy: ReturnType) { + const queryChain: Record = {}; + const chainHandler: ProxyHandler = { + get(_target, prop) { + if (prop === "then") return undefined; + if (prop === "data") return []; + if (prop === "error") return null; + if (prop === "count") return 0; + if (prop === "range") { + return (...args: any[]) => { + rangeSpy(...args); + return new Proxy(queryChain, chainHandler); + }; + } + return (..._args: any[]) => new Proxy(queryChain, chainHandler); + }, + }; + return new Proxy(queryChain, chainHandler); +} + describe("GET /api/affiliates/offers", () => { beforeEach(() => { vi.clearAllMocks(); @@ -98,6 +118,32 @@ describe("GET /api/affiliates/offers", () => { expect(res.status).toBe(200); expect(body.offers[0].product_url).toBeUndefined(); }); + + it("clamps invalid pagination values before querying", async () => { + const rangeSpy = vi.fn(); + mockFrom.mockReturnValue(offerListChain(rangeSpy)); + + const res = await GET(makeRequest({ page: "abc", limit: "-5" })); + const body = await res.json(); + + expect(res.status).toBe(200); + expect(rangeSpy).toHaveBeenCalledWith(0, 0); + expect(body.page).toBe(1); + expect(body.limit).toBe(1); + }); + + it("caps huge pagination values before querying", async () => { + const rangeSpy = vi.fn(); + mockFrom.mockReturnValue(offerListChain(rangeSpy)); + + const res = await GET(makeRequest({ page: "1e308", limit: "999" })); + const body = await res.json(); + + expect(res.status).toBe(200); + expect(rangeSpy).toHaveBeenCalledWith(4999950, 4999999); + expect(body.page).toBe(100000); + expect(body.limit).toBe(50); + }); }); describe("POST /api/affiliates/offers", () => { diff --git a/src/app/api/affiliates/offers/route.ts b/src/app/api/affiliates/offers/route.ts index afac2c94..e430e232 100644 --- a/src/app/api/affiliates/offers/route.ts +++ b/src/app/api/affiliates/offers/route.ts @@ -7,6 +7,16 @@ import { checkRateLimit, rateLimitExceeded, getRateLimitIdentifier } from "@/lib type AnySupabase = any; import { validateOfferInput } from "@/lib/affiliates/validation"; +function parsePaginationParam( + value: string | null, + defaultValue: number, + min: number, + max: number +) { + const parsed = Number(value && value.trim() !== "" ? value : defaultValue); + const finiteValue = Number.isFinite(parsed) ? parsed : defaultValue; + return Math.min(Math.max(Math.trunc(finiteValue), min), max); +} function slugify(text: string): string { return text @@ -22,8 +32,8 @@ function slugify(text: string): string { export async function GET(request: NextRequest) { try { const { searchParams } = new URL(request.url); - const page = Math.max(1, parseInt(searchParams.get("page") || "1")); - const limit = Math.min(50, Math.max(1, parseInt(searchParams.get("limit") || "20"))); + const page = parsePaginationParam(searchParams.get("page"), 1, 1, 100_000); + const limit = parsePaginationParam(searchParams.get("limit"), 20, 1, 50); const category = searchParams.get("category"); const tag = searchParams.get("tag"); const sort = searchParams.get("sort") || "newest";