From 8128c4f0fd557a4f7a40dc314ed4a6b6f52dffcf Mon Sep 17 00:00:00 2001 From: Codex Agent Date: Thu, 4 Jun 2026 16:58:49 -0600 Subject: [PATCH 1/2] Escape agents search filters --- src/lib/queries/agents.test.ts | 16 ++++++++++++++++ src/lib/queries/agents.ts | 10 ++++++++-- src/lib/security/sanitize.ts | 9 +++++++++ 3 files changed, 33 insertions(+), 2 deletions(-) diff --git a/src/lib/queries/agents.test.ts b/src/lib/queries/agents.test.ts index 2665deb1..8ac19b30 100644 --- a/src/lib/queries/agents.test.ts +++ b/src/lib/queries/agents.test.ts @@ -35,6 +35,14 @@ 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("filters by availability when available=true", () => { buildAgentsQuery(mock.client, { available: "true" }); @@ -54,6 +62,14 @@ 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("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..b87f21ef 100644 --- a/src/lib/security/sanitize.ts +++ b/src/lib/security/sanitize.ts @@ -51,3 +51,12 @@ export function sanitizeSearchParams( export function escapePostgrestSearchValue(value: string): string { 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}`); +} From e1b316ecdb226478378221c68545140415dec731 Mon Sep 17 00:00:00 2001 From: Codex Agent Date: Thu, 4 Jun 2026 20:16:50 -0600 Subject: [PATCH 2/2] Cover agents search escape edge cases --- src/lib/queries/agents.test.ts | 16 ++++++++++++++++ src/lib/security/sanitize.ts | 5 +++-- 2 files changed, 19 insertions(+), 2 deletions(-) diff --git a/src/lib/queries/agents.test.ts b/src/lib/queries/agents.test.ts index 8ac19b30..dc531108 100644 --- a/src/lib/queries/agents.test.ts +++ b/src/lib/queries/agents.test.ts @@ -43,6 +43,14 @@ describe("buildAgentsQuery", () => { ); }); + 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" }); @@ -70,6 +78,14 @@ describe("buildAgentsQuery", () => { ); }); + 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/security/sanitize.ts b/src/lib/security/sanitize.ts index b87f21ef..c67f3b9f 100644 --- a/src/lib/security/sanitize.ts +++ b/src/lib/security/sanitize.ts @@ -46,10 +46,11 @@ 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}`); } /**