diff --git a/src/app/api/mcp/route.test.ts b/src/app/api/mcp/route.test.ts index 21251f8a..6f76f810 100644 --- a/src/app/api/mcp/route.test.ts +++ b/src/app/api/mcp/route.test.ts @@ -108,6 +108,69 @@ describe("GET /api/mcp", () => { expect(json.listings).toHaveLength(0); expect(json.total).toBe(0); }); + + it("clamps invalid page values to the first page", async () => { + const range = vi.fn(() => + Promise.resolve({ data: [], count: 0, error: null }) + ); + + mockFrom.mockReturnValue({ + select: () => ({ + eq: () => ({ + order: () => ({ range }), + }), + }), + }); + + const response = await GET(makeGetRequest({ page: "-1" })); + const json = await response.json(); + + expect(response.status).toBe(200); + expect(range).toHaveBeenCalledWith(0, 19); + expect(json.page).toBe(1); + }); + + it("truncates fractional page values before calculating ranges", async () => { + const range = vi.fn(() => + Promise.resolve({ data: [], count: 0, error: null }) + ); + + mockFrom.mockReturnValue({ + select: () => ({ + eq: () => ({ + order: () => ({ range }), + }), + }), + }); + + const response = await GET(makeGetRequest({ page: "2.9" })); + const json = await response.json(); + + expect(response.status).toBe(200); + expect(range).toHaveBeenCalledWith(20, 39); + expect(json.page).toBe(2); + }); + + it("caps huge page values before calculating ranges", async () => { + const range = vi.fn(() => + Promise.resolve({ data: [], count: 0, error: null }) + ); + + mockFrom.mockReturnValue({ + select: () => ({ + eq: () => ({ + order: () => ({ range }), + }), + }), + }); + + const response = await GET(makeGetRequest({ page: "1e308" })); + const json = await response.json(); + + expect(response.status).toBe(200); + expect(range).toHaveBeenCalledWith(1999980, 1999999); + expect(json.page).toBe(100000); + }); }); describe("POST /api/mcp", () => { diff --git a/src/app/api/mcp/route.ts b/src/app/api/mcp/route.ts index c85294ba..568629c6 100644 --- a/src/app/api/mcp/route.ts +++ b/src/app/api/mcp/route.ts @@ -6,6 +6,15 @@ import { mcpListingSchema, slugify } from "@/lib/mcp/validation"; import { combinedScan, MCP_SCANNER_VERSION } from "@/lib/mcp/security-scan"; import { sanitizeSearchParams } from "@/lib/security/sanitize"; +const MAX_PAGE = 100_000; + +function parsePage(value: string | null) { + const parsed = Number(value || "1"); + return Number.isFinite(parsed) + ? Math.min(Math.max(1, Math.trunc(parsed)), MAX_PAGE) + : 1; +} + /** * GET /api/mcp - Public listing of active MCP servers */ @@ -16,7 +25,7 @@ export async function GET(request: NextRequest) { const category = url.searchParams.get("category") || ""; const tag = sanitizeSearchParams(url, "tag"); const sort = url.searchParams.get("sort") || "newest"; - const page = parseInt(url.searchParams.get("page") || "1"); + const page = parsePage(url.searchParams.get("page")); const limit = 20; const offset = (page - 1) * limit; diff --git a/src/app/api/skills/route.test.ts b/src/app/api/skills/route.test.ts index 3b0b1e5c..e7e07cf4 100644 --- a/src/app/api/skills/route.test.ts +++ b/src/app/api/skills/route.test.ts @@ -122,6 +122,69 @@ describe("GET /api/skills", () => { expect(json.total).toBe(1); expect(json.page).toBe(1); }); + + it("clamps invalid page values to the first page", async () => { + const range = vi.fn(() => + Promise.resolve({ data: [], count: 0, error: null }) + ); + + mockFrom.mockReturnValue({ + select: () => ({ + eq: () => ({ + order: () => ({ range }), + }), + }), + }); + + const response = await GET(makeGetRequest({ page: "-1" })); + const json = await response.json(); + + expect(response.status).toBe(200); + expect(range).toHaveBeenCalledWith(0, 19); + expect(json.page).toBe(1); + }); + + it("truncates fractional page values before calculating ranges", async () => { + const range = vi.fn(() => + Promise.resolve({ data: [], count: 0, error: null }) + ); + + mockFrom.mockReturnValue({ + select: () => ({ + eq: () => ({ + order: () => ({ range }), + }), + }), + }); + + const response = await GET(makeGetRequest({ page: "2.9" })); + const json = await response.json(); + + expect(response.status).toBe(200); + expect(range).toHaveBeenCalledWith(20, 39); + expect(json.page).toBe(2); + }); + + it("caps huge page values before calculating ranges", async () => { + const range = vi.fn(() => + Promise.resolve({ data: [], count: 0, error: null }) + ); + + mockFrom.mockReturnValue({ + select: () => ({ + eq: () => ({ + order: () => ({ range }), + }), + }), + }); + + const response = await GET(makeGetRequest({ page: "1e308" })); + const json = await response.json(); + + expect(response.status).toBe(200); + expect(range).toHaveBeenCalledWith(1999980, 1999999); + expect(json.page).toBe(100000); + }); }); describe("POST /api/skills", () => { diff --git a/src/app/api/skills/route.ts b/src/app/api/skills/route.ts index d430dda9..21ae9c76 100644 --- a/src/app/api/skills/route.ts +++ b/src/app/api/skills/route.ts @@ -8,6 +8,15 @@ import { importSkillFromUrl } from "@/lib/skills/url-import"; import { isScanAcceptable } from "@/lib/skills/security-scan"; import { sanitizeSearchParams } from "@/lib/security/sanitize"; +const MAX_PAGE = 100_000; + +function parsePage(value: string | null) { + const parsed = Number(value || "1"); + return Number.isFinite(parsed) + ? Math.min(Math.max(1, Math.trunc(parsed)), MAX_PAGE) + : 1; +} + /** * Detect whether a submission looks like an MCP server listing based on title/tags. */ @@ -32,7 +41,7 @@ export async function GET(request: NextRequest) { const category = url.searchParams.get("category") || ""; const tag = sanitizeSearchParams(url, "tag"); const sort = url.searchParams.get("sort") || "newest"; - const page = parseInt(url.searchParams.get("page") || "1"); + const page = parsePage(url.searchParams.get("page")); const limit = 20; const offset = (page - 1) * limit;