From 0dbf3490bf3964968eb3c4c7a0055ded5db25c75 Mon Sep 17 00:00:00 2001 From: GhaZy Date: Fri, 30 Oct 2026 00:13:41 +0200 Subject: [PATCH 1/2] fix(web): validate page and limit query params on caps and space dashboard (#3567) caps/page.tsx and spaces/[spaceId]/page.tsx parsed page and limit via Number(value) || default, allowing negative (-1) and fractional (1.5, 2.5) numbers to reach MySQL OFFSET and LIMIT clauses, causing syntax errors and client-side page crashes. Introduced parsePageParam and parseLimitParam in apps/web/lib/pagination.ts to validate positive integers with safe fallbacks and bounds, reused by parsePublicCollectionPage. Added comprehensive unit tests. Closes #3567 --- .../__tests__/unit/pagination-params.test.ts | 49 +++++++++++++++++++ apps/web/app/(org)/dashboard/caps/page.tsx | 5 +- .../(org)/dashboard/spaces/[spaceId]/page.tsx | 5 +- apps/web/lib/pagination.ts | 24 +++++++++ apps/web/lib/public-collections-policy.ts | 8 +-- 5 files changed, 81 insertions(+), 10 deletions(-) create mode 100644 apps/web/__tests__/unit/pagination-params.test.ts create mode 100644 apps/web/lib/pagination.ts diff --git a/apps/web/__tests__/unit/pagination-params.test.ts b/apps/web/__tests__/unit/pagination-params.test.ts new file mode 100644 index 00000000000..06a2d7b7a65 --- /dev/null +++ b/apps/web/__tests__/unit/pagination-params.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it } from "vitest"; +import { parseLimitParam, parsePageParam } from "../../lib/pagination"; + +describe("pagination params validation (#3567)", () => { + describe("parsePageParam", () => { + it("falls back to default page on missing, non-positive, or fractional input", () => { + expect(parsePageParam(undefined)).toBe(1); + expect(parsePageParam("")).toBe(1); + expect(parsePageParam("0")).toBe(1); + expect(parsePageParam("-1")).toBe(1); + expect(parsePageParam("-10")).toBe(1); + expect(parsePageParam("1.5")).toBe(1); + expect(parsePageParam("abc")).toBe(1); + expect(parsePageParam(NaN as unknown as string)).toBe(1); + }); + + it("parses valid positive integer pages", () => { + expect(parsePageParam("1")).toBe(1); + expect(parsePageParam("5")).toBe(5); + expect(parsePageParam(["3", "4"])).toBe(3); + }); + + it("supports custom default page", () => { + expect(parsePageParam("-5", 2)).toBe(2); + }); + }); + + describe("parseLimitParam", () => { + it("falls back to default limit on missing, non-positive, or fractional input", () => { + expect(parseLimitParam(undefined, 15)).toBe(15); + expect(parseLimitParam("", 15)).toBe(15); + expect(parseLimitParam("0", 15)).toBe(15); + expect(parseLimitParam("-5", 15)).toBe(15); + expect(parseLimitParam("2.5", 15)).toBe(15); + expect(parseLimitParam("foo", 15)).toBe(15); + }); + + it("parses valid positive integer limits", () => { + expect(parseLimitParam("10", 15)).toBe(10); + expect(parseLimitParam("25", 15)).toBe(25); + expect(parseLimitParam(["20", "30"], 15)).toBe(20); + }); + + it("caps oversized limits to maxLimit", () => { + expect(parseLimitParam("500", 15, 100)).toBe(100); + expect(parseLimitParam("100", 15, 100)).toBe(100); + }); + }); +}); diff --git a/apps/web/app/(org)/dashboard/caps/page.tsx b/apps/web/app/(org)/dashboard/caps/page.tsx index 51696e1eb10..705b774e197 100644 --- a/apps/web/app/(org)/dashboard/caps/page.tsx +++ b/apps/web/app/(org)/dashboard/caps/page.tsx @@ -24,6 +24,7 @@ import type { Metadata } from "next"; import { redirect } from "next/navigation"; import { seedThumbnailUrls } from "@/lib/seeded-thumbnails"; import { runPromise } from "@/lib/server"; +import { parseLimitParam, parsePageParam } from "@/lib/pagination"; import { Caps } from "./Caps"; export const metadata: Metadata = { @@ -133,8 +134,8 @@ export default async function CapsPage(props: PageProps<"/dashboard/caps">) { redirect("/login"); } - const page = Number(searchParams.page) || 1; - const limit = Number(searchParams.limit) || 15; + const page = parsePageParam(searchParams.page, 1); + const limit = parseLimitParam(searchParams.limit, 15); const userId = user.id; const offset = (page - 1) * limit; diff --git a/apps/web/app/(org)/dashboard/spaces/[spaceId]/page.tsx b/apps/web/app/(org)/dashboard/spaces/[spaceId]/page.tsx index df563360460..67e0e126fd1 100644 --- a/apps/web/app/(org)/dashboard/spaces/[spaceId]/page.tsx +++ b/apps/web/app/(org)/dashboard/spaces/[spaceId]/page.tsx @@ -34,6 +34,7 @@ import type { Metadata } from "next"; import { notFound } from "next/navigation"; import { seedThumbnailUrls } from "@/lib/seeded-thumbnails"; import { runPromise } from "@/lib/server"; +import { parseLimitParam, parsePageParam } from "@/lib/pagination"; import { SharedCaps } from "./SharedCaps"; export const metadata: Metadata = { @@ -288,8 +289,8 @@ export default async function SharedCapsPage(props: { }) { const searchParams = await props.searchParams; const params = await props.params; - const page = Number(searchParams.page) || 1; - const limit = Number(searchParams.limit) || 15; + const page = parsePageParam(searchParams.page, 1); + const limit = parseLimitParam(searchParams.limit, 15); const user = await getCurrentUser(); if (!user) notFound(); diff --git a/apps/web/lib/pagination.ts b/apps/web/lib/pagination.ts new file mode 100644 index 00000000000..1a15eabb730 --- /dev/null +++ b/apps/web/lib/pagination.ts @@ -0,0 +1,24 @@ +export function parsePageParam( + page: string | string[] | undefined, + defaultValue = 1, +): number { + const value = Array.isArray(page) ? page[0] : page; + const parsed = Number(value); + + if (!Number.isInteger(parsed) || parsed < 1) return defaultValue; + + return parsed; +} + +export function parseLimitParam( + limit: string | string[] | undefined, + defaultValue = 15, + maxLimit = 100, +): number { + const value = Array.isArray(limit) ? limit[0] : limit; + const parsed = Number(value); + + if (!Number.isInteger(parsed) || parsed < 1) return defaultValue; + + return Math.min(parsed, maxLimit); +} diff --git a/apps/web/lib/public-collections-policy.ts b/apps/web/lib/public-collections-policy.ts index 5d42e5f049f..fc6c1030094 100644 --- a/apps/web/lib/public-collections-policy.ts +++ b/apps/web/lib/public-collections-policy.ts @@ -1,4 +1,5 @@ import { isEmailAllowedByRestriction } from "@cap/utils"; +import { parsePageParam } from "./pagination"; export const PUBLIC_COLLECTION_PAGE_SIZE = 15; @@ -19,12 +20,7 @@ export type PublicCollectionAccess = export function parsePublicCollectionPage( page: string | string[] | undefined, ): number { - const value = Array.isArray(page) ? page[0] : page; - const parsed = Number(value); - - if (!Number.isInteger(parsed) || parsed < 1) return 1; - - return parsed; + return parsePageParam(page, 1); } export function getPublicCollectionHref(id: string, page: number): string { From f52b5cf1c0a01b0e04633cafb82c2a5ccf0562f8 Mon Sep 17 00:00:00 2001 From: GhaZy Date: Sat, 31 Oct 2026 00:18:36 +0200 Subject: [PATCH 2/2] style(web): sort imports with biome in dashboard pagination pages (#3567) --- apps/web/app/(org)/dashboard/caps/page.tsx | 2 +- apps/web/app/(org)/dashboard/spaces/[spaceId]/page.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/web/app/(org)/dashboard/caps/page.tsx b/apps/web/app/(org)/dashboard/caps/page.tsx index 705b774e197..67951bad6ce 100644 --- a/apps/web/app/(org)/dashboard/caps/page.tsx +++ b/apps/web/app/(org)/dashboard/caps/page.tsx @@ -22,9 +22,9 @@ import { and, count, desc, eq, inArray, isNull, sql } from "drizzle-orm"; import { type Array, Effect } from "effect"; import type { Metadata } from "next"; import { redirect } from "next/navigation"; +import { parseLimitParam, parsePageParam } from "@/lib/pagination"; import { seedThumbnailUrls } from "@/lib/seeded-thumbnails"; import { runPromise } from "@/lib/server"; -import { parseLimitParam, parsePageParam } from "@/lib/pagination"; import { Caps } from "./Caps"; export const metadata: Metadata = { diff --git a/apps/web/app/(org)/dashboard/spaces/[spaceId]/page.tsx b/apps/web/app/(org)/dashboard/spaces/[spaceId]/page.tsx index 67e0e126fd1..64cf5af2707 100644 --- a/apps/web/app/(org)/dashboard/spaces/[spaceId]/page.tsx +++ b/apps/web/app/(org)/dashboard/spaces/[spaceId]/page.tsx @@ -32,9 +32,9 @@ import { and, count, desc, eq, inArray, isNull, or, sql } from "drizzle-orm"; import { Effect } from "effect"; import type { Metadata } from "next"; import { notFound } from "next/navigation"; +import { parseLimitParam, parsePageParam } from "@/lib/pagination"; import { seedThumbnailUrls } from "@/lib/seeded-thumbnails"; import { runPromise } from "@/lib/server"; -import { parseLimitParam, parsePageParam } from "@/lib/pagination"; import { SharedCaps } from "./SharedCaps"; export const metadata: Metadata = {