diff --git a/src/lib/queries/agents.test.ts b/src/lib/queries/agents.test.ts index 2665deb1..dc531108 100644 --- a/src/lib/queries/agents.test.ts +++ b/src/lib/queries/agents.test.ts @@ -35,6 +35,22 @@ describe("buildAgentsQuery", () => { ); }); + it("escapes PostgREST search syntax in agent search terms", () => { + buildAgentsQuery(mock.client, { q: "ai_agent,pro.v2" }); + + expect(mock.chain.or).toHaveBeenCalledWith( + String.raw`full_name.ilike.%ai\_agent\,pro\.v2%,username.ilike.%ai\_agent\,pro\.v2%,bio.ilike.%ai\_agent\,pro\.v2%` + ); + }); + + it("escapes LIKE wildcards and escape characters in agent search terms", () => { + buildAgentsQuery(mock.client, { q: String.raw`100%\agent*` }); + + expect(mock.chain.or).toHaveBeenCalledWith( + String.raw`full_name.ilike.%100\%\\agent\*%,username.ilike.%100\%\\agent\*%,bio.ilike.%100\%\\agent\*%` + ); + }); + it("filters by availability when available=true", () => { buildAgentsQuery(mock.client, { available: "true" }); @@ -54,6 +70,22 @@ describe("buildAgentsQuery", () => { expect(mock.chain.or).toHaveBeenCalledWith('skills.cs.{"node"},ai_tools.cs.{"node"}'); }); + it("escapes PostgREST array literal syntax in tag filters", () => { + buildAgentsQuery(mock.client, { tags: ['react,"admin"}'] }); + + expect(mock.chain.or).toHaveBeenCalledWith( + String.raw`skills.cs.{"react\,\"admin\"\}"},ai_tools.cs.{"react\,\"admin\"\}"}` + ); + }); + + it("escapes opening braces in tag filters", () => { + buildAgentsQuery(mock.client, { tags: ['{admin}'] }); + + expect(mock.chain.or).toHaveBeenCalledWith( + String.raw`skills.cs.{"\{admin\}"},ai_tools.cs.{"\{admin\}"}` + ); + }); + it("sorts by rate_high descending", () => { buildAgentsQuery(mock.client, { sort: "rate_high" }); diff --git a/src/lib/queries/agents.ts b/src/lib/queries/agents.ts index 69576b25..bf3e9dab 100644 --- a/src/lib/queries/agents.ts +++ b/src/lib/queries/agents.ts @@ -1,4 +1,8 @@ import { SupabaseClient } from "@supabase/supabase-js"; +import { + escapePostgrestArrayLiteralValue, + escapePostgrestSearchValue, +} from "@/lib/security/sanitize"; const MAX_PAGE = 100_000; @@ -31,8 +35,9 @@ export function buildAgentsQuery( .eq("is_spam", false); if (q) { + const escapedQuery = escapePostgrestSearchValue(q); query = query.or( - `full_name.ilike.%${q}%,username.ilike.%${q}%,bio.ilike.%${q}%` + `full_name.ilike.%${escapedQuery}%,username.ilike.%${escapedQuery}%,bio.ilike.%${escapedQuery}%` ); } @@ -41,7 +46,8 @@ export function buildAgentsQuery( } for (const tag of tags) { - query = query.or(`skills.cs.{"${tag}"},ai_tools.cs.{"${tag}"}`); + const escapedTag = escapePostgrestArrayLiteralValue(tag); + query = query.or(`skills.cs.{"${escapedTag}"},ai_tools.cs.{"${escapedTag}"}`); } switch (sort) { diff --git a/src/lib/security/sanitize.ts b/src/lib/security/sanitize.ts index 790b3c14..c67f3b9f 100644 --- a/src/lib/security/sanitize.ts +++ b/src/lib/security/sanitize.ts @@ -46,8 +46,18 @@ 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}`); +} + +/** + * Escape user text before placing it inside a quoted PostgREST array literal. + * Array literals add braces and quotes as syntax, so escape those in addition + * to the search punctuation handled above. + */ +export function escapePostgrestArrayLiteralValue(value: string): string { + return escapePostgrestSearchValue(value).replace(/["{}]/g, (char) => `\\${char}`); }