From 302800d40b0436434ca8cbffef8dc2ae8d8576b4 Mon Sep 17 00:00:00 2001 From: Codex Agent Date: Thu, 4 Jun 2026 15:16:04 -0600 Subject: [PATCH] Escape marketplace search filters --- src/app/api/affiliates/offers/route.test.ts | 25 +++++++++++++++++--- src/app/api/affiliates/offers/route.ts | 4 +++- src/app/api/mcp/route.test.ts | 26 +++++++++++++++++++++ src/app/api/mcp/route.ts | 5 ++-- src/app/api/search/route.test.ts | 11 +++++++++ src/app/api/search/route.ts | 4 ++-- src/app/api/skills/route.test.ts | 26 +++++++++++++++++++++ src/app/api/skills/route.ts | 5 ++-- src/lib/security/sanitize.test.ts | 10 +++++++- src/lib/security/sanitize.ts | 9 +++++++ 10 files changed, 114 insertions(+), 11 deletions(-) diff --git a/src/app/api/affiliates/offers/route.test.ts b/src/app/api/affiliates/offers/route.test.ts index 5fcc5f21..134e0615 100644 --- a/src/app/api/affiliates/offers/route.test.ts +++ b/src/app/api/affiliates/offers/route.test.ts @@ -98,11 +98,30 @@ describe("GET /api/affiliates/offers", () => { }); it("filters by search query (#21)", async () => { - mockFrom.mockReturnValue(chainable([], null, 0)); + const orSpy = vi.fn(); + 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 === "or") { + return (...args: any[]) => { + orSpy(...args); + return new Proxy(queryChain, chainHandler); + }; + } + return (..._args: any[]) => new Proxy(queryChain, chainHandler); + }, + }; + mockFrom.mockReturnValue(new Proxy(queryChain, chainHandler)); - const res = await GET(makeRequest({ q: "test search" })); + const res = await GET(makeRequest({ q: "100%_offer,(v1.2)" })); expect(res.status).toBe(200); - expect(mockFrom).toHaveBeenCalled(); + expect(orSpy).toHaveBeenCalledWith( + "title.ilike.%100\\%\\_offer\\,\\(v1\\.2\\)%,description.ilike.%100\\%\\_offer\\,\\(v1\\.2\\)%" + ); }); it("hides product_url from unauthenticated users (#20)", async () => { diff --git a/src/app/api/affiliates/offers/route.ts b/src/app/api/affiliates/offers/route.ts index e430e232..14c149bb 100644 --- a/src/app/api/affiliates/offers/route.ts +++ b/src/app/api/affiliates/offers/route.ts @@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from "next/server"; import { getAuthContext } from "@/lib/auth/get-user"; import { createServiceClient } from "@/lib/supabase/service"; import { checkRateLimit, rateLimitExceeded, getRateLimitIdentifier } from "@/lib/rate-limit"; +import { escapePostgrestSearchValue } from "@/lib/security/sanitize"; // eslint-disable-next-line @typescript-eslint/no-explicit-any type AnySupabase = any; @@ -64,7 +65,8 @@ export async function GET(request: NextRequest) { if (slugFilter) { query = query.eq("slug", slugFilter); } else if (search) { - query = query.or(`title.ilike.%${search}%,description.ilike.%${search}%`); + const safeSearch = escapePostgrestSearchValue(search); + query = query.or(`title.ilike.%${safeSearch}%,description.ilike.%${safeSearch}%`); } // Sort diff --git a/src/app/api/mcp/route.test.ts b/src/app/api/mcp/route.test.ts index 6f76f810..3b080221 100644 --- a/src/app/api/mcp/route.test.ts +++ b/src/app/api/mcp/route.test.ts @@ -89,6 +89,32 @@ describe("GET /api/mcp", () => { expect(json.page).toBe(1); }); + it("escapes search text before building PostgREST filters", async () => { + const orSpy = vi.fn(() => ({ + order: () => ({ + range: () => Promise.resolve({ data: [], count: 0, error: null }), + }), + })); + + mockFrom.mockReturnValue({ + select: () => ({ + eq: () => ({ + or: orSpy, + order: () => ({ + range: () => Promise.resolve({ data: [], count: 0, error: null }), + }), + }), + }), + }); + + const response = await GET(makeGetRequest({ search: "100%_mcp,(v1.2)" })); + + expect(response.status).toBe(200); + expect(orSpy).toHaveBeenCalledWith( + "title.ilike.%100\\%\\_mcp\\,\\(v1\\.2\\)%,description.ilike.%100\\%\\_mcp\\,\\(v1\\.2\\)%,tagline.ilike.%100\\%\\_mcp\\,\\(v1\\.2\\)%" + ); + }); + it("returns empty listings when none exist", async () => { mockFrom.mockReturnValue({ select: () => ({ diff --git a/src/app/api/mcp/route.ts b/src/app/api/mcp/route.ts index 568629c6..5ca12eb3 100644 --- a/src/app/api/mcp/route.ts +++ b/src/app/api/mcp/route.ts @@ -4,7 +4,7 @@ import { getAuthContext } from "@/lib/auth/get-user"; import { createServiceClient } from "@/lib/supabase/service"; import { mcpListingSchema, slugify } from "@/lib/mcp/validation"; import { combinedScan, MCP_SCANNER_VERSION } from "@/lib/mcp/security-scan"; -import { sanitizeSearchParams } from "@/lib/security/sanitize"; +import { escapePostgrestSearchValue, sanitizeSearchParams } from "@/lib/security/sanitize"; const MAX_PAGE = 100_000; @@ -40,7 +40,8 @@ export async function GET(request: NextRequest) { .eq("status", "active"); if (search) { - query = query.or(`title.ilike.%${search}%,description.ilike.%${search}%,tagline.ilike.%${search}%`); + const safeSearch = escapePostgrestSearchValue(search); + query = query.or(`title.ilike.%${safeSearch}%,description.ilike.%${safeSearch}%,tagline.ilike.%${safeSearch}%`); } if (category) { diff --git a/src/app/api/search/route.test.ts b/src/app/api/search/route.test.ts index c7fe99f9..96a1ba0d 100644 --- a/src/app/api/search/route.test.ts +++ b/src/app/api/search/route.test.ts @@ -327,6 +327,17 @@ describe("GET /api/search", () => { expect(orArg).toContain("my\\_var"); }); + it("escapes PostgREST filter punctuation in search query", async () => { + const chain = chainResult({ data: [], error: null, count: 0 }); + mockFrom.mockReturnValue(chain); + + await GET(makeRequest({ q: "foo,(v1.2)", type: "posts" })); + + expect(chain.or).toHaveBeenCalled(); + const orArg = chain.or.mock.calls[0][0] as string; + expect(orArg).toBe("content.ilike.%foo\\,\\(v1\\.2\\)%"); + }); + // ── Empty results ───────────────────────────────────────────── it("returns proper empty structure for type=all", async () => { diff --git a/src/app/api/search/route.ts b/src/app/api/search/route.ts index c42e7f9c..bff5c08c 100644 --- a/src/app/api/search/route.ts +++ b/src/app/api/search/route.ts @@ -1,5 +1,6 @@ import { NextRequest, NextResponse } from "next/server"; import { createClient } from "@/lib/supabase/server"; +import { escapePostgrestSearchValue } from "@/lib/security/sanitize"; type SearchType = "gigs" | "agents" | "posts" | "all"; @@ -40,8 +41,7 @@ export async function GET(request: NextRequest) { const supabase = await createClient(); const offset = (page - 1) * limit; - // Escape special ilike characters - const escaped = query.replace(/%/g, "\\%").replace(/_/g, "\\_"); + const escaped = escapePostgrestSearchValue(query); const pattern = `%${escaped}%`; const results: Record = {}; diff --git a/src/app/api/skills/route.test.ts b/src/app/api/skills/route.test.ts index e7e07cf4..9a238e38 100644 --- a/src/app/api/skills/route.test.ts +++ b/src/app/api/skills/route.test.ts @@ -123,6 +123,32 @@ describe("GET /api/skills", () => { expect(json.page).toBe(1); }); + it("escapes search text before building PostgREST filters", async () => { + const orSpy = vi.fn(() => ({ + order: () => ({ + range: () => Promise.resolve({ data: [], count: 0, error: null }), + }), + })); + + mockFrom.mockReturnValue({ + select: () => ({ + eq: () => ({ + or: orSpy, + order: () => ({ + range: () => Promise.resolve({ data: [], count: 0, error: null }), + }), + }), + }), + }); + + const response = await GET(makeGetRequest({ search: "100%_mcp,(v1.2)" })); + + expect(response.status).toBe(200); + expect(orSpy).toHaveBeenCalledWith( + "title.ilike.%100\\%\\_mcp\\,\\(v1\\.2\\)%,description.ilike.%100\\%\\_mcp\\,\\(v1\\.2\\)%,tagline.ilike.%100\\%\\_mcp\\,\\(v1\\.2\\)%" + ); + }); + it("clamps invalid page values to the first page", async () => { const range = vi.fn(() => Promise.resolve({ data: [], count: 0, error: null }) diff --git a/src/app/api/skills/route.ts b/src/app/api/skills/route.ts index 21ae9c76..1a5ef0c8 100644 --- a/src/app/api/skills/route.ts +++ b/src/app/api/skills/route.ts @@ -6,7 +6,7 @@ import { skillListingSchema } from "@/lib/skills/validation"; import { slugify } from "@/lib/skills/validation"; import { importSkillFromUrl } from "@/lib/skills/url-import"; import { isScanAcceptable } from "@/lib/skills/security-scan"; -import { sanitizeSearchParams } from "@/lib/security/sanitize"; +import { escapePostgrestSearchValue, sanitizeSearchParams } from "@/lib/security/sanitize"; const MAX_PAGE = 100_000; @@ -56,7 +56,8 @@ export async function GET(request: NextRequest) { .eq("status", "active"); if (search) { - query = query.or(`title.ilike.%${search}%,description.ilike.%${search}%,tagline.ilike.%${search}%`); + const safeSearch = escapePostgrestSearchValue(search); + query = query.or(`title.ilike.%${safeSearch}%,description.ilike.%${safeSearch}%,tagline.ilike.%${safeSearch}%`); } if (category) { diff --git a/src/lib/security/sanitize.test.ts b/src/lib/security/sanitize.test.ts index 5ae0e686..b7df5df0 100644 --- a/src/lib/security/sanitize.test.ts +++ b/src/lib/security/sanitize.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from "vitest"; -import { sanitizeUrlParam, sanitizeSearchParams } from "./sanitize"; +import { escapePostgrestSearchValue, sanitizeUrlParam, sanitizeSearchParams } from "./sanitize"; describe("sanitizeUrlParam", () => { it("should return empty string for null/undefined input", () => { @@ -51,3 +51,11 @@ describe("sanitizeSearchParams", () => { expect(sanitizeSearchParams(url, "missing")).toBe(""); }); }); + +describe("escapePostgrestSearchValue", () => { + it("escapes LIKE wildcards and PostgREST filter punctuation", () => { + 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 f7099e4d..790b3c14 100644 --- a/src/lib/security/sanitize.ts +++ b/src/lib/security/sanitize.ts @@ -42,3 +42,12 @@ export function sanitizeSearchParams( const value = url.searchParams.get(param); return sanitizeUrlParam(value); } + +/** + * 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. + */ +export function escapePostgrestSearchValue(value: string): string { + return value.replace(/[\\%_,().]/g, (char) => `\\${char}`); +}