diff --git a/src/app/affiliates/page.tsx b/src/app/affiliates/page.tsx index 397bdad9..c1e9cdaa 100644 --- a/src/app/affiliates/page.tsx +++ b/src/app/affiliates/page.tsx @@ -9,6 +9,7 @@ import { Avatar, AvatarImage, AvatarFallback } from "@/components/ui/avatar"; import { Megaphone, Users, TrendingUp, Zap } from "lucide-react"; import { SKILL_CATEGORIES } from "@/lib/constants"; import { parsePageParam } from "@/lib/pagination"; +import { escapePostgrestSearchValue } from "@/lib/security/sanitize"; export const metadata: Metadata = { title: "Affiliate Marketplace | ugig.net", @@ -115,8 +116,9 @@ async function AffiliatesList({ searchParams }: { searchParams: AffiliatesPagePr // Limit search param length to prevent oversized queries (#57) const searchTerm = queryParams.search?.slice(0, 200); if (searchTerm) { + const safeSearchTerm = escapePostgrestSearchValue(searchTerm); query = query.or( - `title.ilike.%${searchTerm}%,description.ilike.%${searchTerm}%` + `title.ilike.%${safeSearchTerm}%,description.ilike.%${safeSearchTerm}%` ); } diff --git a/src/app/directory/page.tsx b/src/app/directory/page.tsx index 054ca9f4..2ad9775e 100644 --- a/src/app/directory/page.tsx +++ b/src/app/directory/page.tsx @@ -9,6 +9,7 @@ import { Skeleton } from "@/components/ui/skeleton"; import { Avatar, AvatarImage, AvatarFallback } from "@/components/ui/avatar"; import { FolderOpen, ExternalLink, Zap, ThumbsUp, MessageSquare } from "lucide-react"; import { parsePageParam } from "@/lib/pagination"; +import { escapePostgrestSearchValue } from "@/lib/security/sanitize"; export const metadata: Metadata = { title: "Project Directory | ugig.net", @@ -53,8 +54,9 @@ async function DirectoryList({ .eq("status", "active"); if (queryParams.search) { + const safeSearch = escapePostgrestSearchValue(queryParams.search); query = query.or( - `title.ilike.%${queryParams.search}%,description.ilike.%${queryParams.search}%` + `title.ilike.%${safeSearch}%,description.ilike.%${safeSearch}%` ); } diff --git a/src/app/for-hire/[[...tags]]/page.tsx b/src/app/for-hire/[[...tags]]/page.tsx index 0c215a7d..b29e2258 100644 --- a/src/app/for-hire/[[...tags]]/page.tsx +++ b/src/app/for-hire/[[...tags]]/page.tsx @@ -8,6 +8,7 @@ import { Button } from "@/components/ui/button"; import { Skeleton } from "@/components/ui/skeleton"; import { Header } from "@/components/layout/Header"; import { parsePageParam } from "@/lib/pagination"; +import { escapePostgrestSearchValue } from "@/lib/security/sanitize"; import { Briefcase } from "lucide-react"; interface GigsPageProps { @@ -90,8 +91,9 @@ async function GigsList({ // Filter by search query if (queryParams.search) { + const safeSearch = escapePostgrestSearchValue(queryParams.search); query = query.or( - `title.ilike.%${queryParams.search}%,description.ilike.%${queryParams.search}%` + `title.ilike.%${safeSearch}%,description.ilike.%${safeSearch}%` ); } diff --git a/src/app/gigs/[[...tags]]/page.tsx b/src/app/gigs/[[...tags]]/page.tsx index cc6a1d5a..6d0cd33c 100644 --- a/src/app/gigs/[[...tags]]/page.tsx +++ b/src/app/gigs/[[...tags]]/page.tsx @@ -8,6 +8,7 @@ import { Button } from "@/components/ui/button"; import { Skeleton } from "@/components/ui/skeleton"; import { Header } from "@/components/layout/Header"; import { parsePageParam } from "@/lib/pagination"; +import { escapePostgrestSearchValue } from "@/lib/security/sanitize"; import { Briefcase } from "lucide-react"; interface GigsPageProps { @@ -91,8 +92,9 @@ async function GigsList({ // Filter by search query if (queryParams.search) { + const safeSearch = escapePostgrestSearchValue(queryParams.search); query = query.or( - `title.ilike.%${queryParams.search}%,description.ilike.%${queryParams.search}%` + `title.ilike.%${safeSearch}%,description.ilike.%${safeSearch}%` ); } diff --git a/src/app/mcp/page.tsx b/src/app/mcp/page.tsx index b0bbe67b..c0764ffc 100644 --- a/src/app/mcp/page.tsx +++ b/src/app/mcp/page.tsx @@ -11,6 +11,7 @@ import { Server, Star, Download, Zap } from "lucide-react"; import { MCP_CATEGORIES } from "@/lib/constants"; import { CopyLinkButton } from "@/components/ui/CopyLinkButton"; import { parsePageParam } from "@/lib/pagination"; +import { escapePostgrestSearchValue } from "@/lib/security/sanitize"; export const metadata: Metadata = { title: "MCP Server Marketplace | ugig.net", @@ -69,8 +70,9 @@ async function McpList({ searchParams }: { searchParams: McpPageProps["searchPar .eq("status", "active"); if (queryParams.search) { + const safeSearch = escapePostgrestSearchValue(queryParams.search); query = query.or( - `title.ilike.%${queryParams.search}%,description.ilike.%${queryParams.search}%,tagline.ilike.%${queryParams.search}%` + `title.ilike.%${safeSearch}%,description.ilike.%${safeSearch}%,tagline.ilike.%${safeSearch}%` ); } diff --git a/src/app/prompts/page.tsx b/src/app/prompts/page.tsx index 95a79c50..4573e842 100644 --- a/src/app/prompts/page.tsx +++ b/src/app/prompts/page.tsx @@ -11,6 +11,7 @@ import { FileText, Star, Download, Zap } from "lucide-react"; import { PROMPT_CATEGORIES } from "@/lib/constants"; import { CopyLinkButton } from "@/components/ui/CopyLinkButton"; import { parsePageParam } from "@/lib/pagination"; +import { escapePostgrestSearchValue } from "@/lib/security/sanitize"; export const metadata: Metadata = { title: "Prompt Marketplace | ugig.net", @@ -69,8 +70,9 @@ async function PromptList({ searchParams }: { searchParams: PromptsPageProps["se .eq("status", "active"); if (queryParams.search) { + const safeSearch = escapePostgrestSearchValue(queryParams.search); query = query.or( - `title.ilike.%${queryParams.search}%,description.ilike.%${queryParams.search}%,tagline.ilike.%${queryParams.search}%` + `title.ilike.%${safeSearch}%,description.ilike.%${safeSearch}%,tagline.ilike.%${safeSearch}%` ); } diff --git a/src/app/skills/page.tsx b/src/app/skills/page.tsx index 6f1d9731..fc49aed9 100644 --- a/src/app/skills/page.tsx +++ b/src/app/skills/page.tsx @@ -10,6 +10,7 @@ import { Avatar, AvatarImage, AvatarFallback } from "@/components/ui/avatar"; import { Package, Star, Download, Zap, ShieldCheck, ShieldAlert, ShieldX, Shield } from "lucide-react"; import { SKILL_CATEGORIES, SUPPORTED_AGENT_OPTIONS } from "@/lib/constants"; import { parsePageParam } from "@/lib/pagination"; +import { escapePostgrestSearchValue } from "@/lib/security/sanitize"; export const metadata: Metadata = { title: "AI Agent Skills Marketplace | ugig.net", @@ -68,8 +69,9 @@ async function SkillsList({ searchParams }: { searchParams: SkillsPageProps["sea .eq("status", "active"); if (queryParams.search) { + const safeSearch = escapePostgrestSearchValue(queryParams.search); query = query.or( - `title.ilike.%${queryParams.search}%,description.ilike.%${queryParams.search}%,tagline.ilike.%${queryParams.search}%` + `title.ilike.%${safeSearch}%,description.ilike.%${safeSearch}%,tagline.ilike.%${safeSearch}%` ); } diff --git a/src/lib/security/sanitize.test.ts b/src/lib/security/sanitize.test.ts index b7df5df0..78954d24 100644 --- a/src/lib/security/sanitize.test.ts +++ b/src/lib/security/sanitize.test.ts @@ -54,8 +54,8 @@ describe("sanitizeSearchParams", () => { describe("escapePostgrestSearchValue", () => { it("escapes LIKE wildcards and PostgREST filter punctuation", () => { - expect(escapePostgrestSearchValue("100%_match,(v1.2)")).toBe( - "100\\%\\_match\\,\\(v1\\.2\\)" + expect(escapePostgrestSearchValue("100%_match*,(v1.2)")).toBe( + "100\\%\\_match\\*\\,\\(v1\\.2\\)" ); }); }); diff --git a/src/lib/security/sanitize.ts b/src/lib/security/sanitize.ts index 790b3c14..4bcc81be 100644 --- a/src/lib/security/sanitize.ts +++ b/src/lib/security/sanitize.ts @@ -46,8 +46,9 @@ export function sanitizeSearchParams( /** * Escape user text before interpolating it into a PostgREST filter string. * PostgREST uses punctuation such as commas, periods, and parentheses as - * filter syntax, while SQL LIKE treats % and _ as wildcards. + * filter syntax, while SQL LIKE treats % and _ as wildcards. PostgREST also + * accepts * as a % alias in like/ilike filters. */ export function escapePostgrestSearchValue(value: string): string { - return value.replace(/[\\%_,().]/g, (char) => `\\${char}`); + return value.replace(/[\\%*_,().]/g, (char) => `\\${char}`); }