Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion src/app/affiliates/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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}%`
);
}

Expand Down
4 changes: 3 additions & 1 deletion src/app/directory/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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}%`
);
}
Comment on lines 56 to 61
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Missing search-term length cap on six of seven pages

affiliates/page.tsx slices the search value to 200 characters before escaping (with a comment citing issue #57), but directory, for-hire, gigs, mcp, prompts, and skills pages pass queryParams.search directly without any length limit. A very long search string still produces an oversized PostgREST filter string on those routes. Since this PR is touching all of them, it would be a natural place to apply the same .slice(0, 200) guard consistently.


Expand Down
4 changes: 3 additions & 1 deletion src/app/for-hire/[[...tags]]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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}%`
);
}

Expand Down
4 changes: 3 additions & 1 deletion src/app/gigs/[[...tags]]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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}%`
);
}

Expand Down
4 changes: 3 additions & 1 deletion src/app/mcp/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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}%`
);
}

Expand Down
4 changes: 3 additions & 1 deletion src/app/prompts/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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}%`
);
}

Expand Down
4 changes: 3 additions & 1 deletion src/app/skills/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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}%`
);
}

Expand Down
4 changes: 2 additions & 2 deletions src/lib/security/sanitize.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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\\)"
);
});
});
Comment on lines 55 to 61
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 The test suite has no case for an input that contains a backslash. The function escapes \ first (it's at the front of the character class [\\%*_,().]), so a double-escape regression — e.g. input \% producing \\\% instead of \\% — would go undetected. Adding a single backslash-containing case closes this gap.

Suggested change
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\\)"
);
});
});
describe("escapePostgrestSearchValue", () => {
it("escapes LIKE wildcards and PostgREST filter punctuation", () => {
expect(escapePostgrestSearchValue("100%_match*,(v1.2)")).toBe(
"100\\%\\_match\\*\\,\\(v1\\.2\\)"
);
});
it("escapes backslash to prevent double-escaping", () => {
expect(escapePostgrestSearchValue("50\\%off")).toBe("50\\\\\\%off");
});
});

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

5 changes: 3 additions & 2 deletions src/lib/security/sanitize.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}`);
}
Loading