From c816424e9423cb8feef4827e85eddd54c7f6bdbf Mon Sep 17 00:00:00 2001 From: Codex Agent Date: Thu, 4 Jun 2026 20:29:04 -0600 Subject: [PATCH] Escape candidates search filters --- src/lib/queries/candidates.test.ts | 32 ++++++++++++++++++++++++++++++ src/lib/queries/candidates.ts | 10 ++++++++-- src/lib/security/sanitize.ts | 14 +++++++++++-- 3 files changed, 52 insertions(+), 4 deletions(-) diff --git a/src/lib/queries/candidates.test.ts b/src/lib/queries/candidates.test.ts index 1e9b3afa..a3f06987 100644 --- a/src/lib/queries/candidates.test.ts +++ b/src/lib/queries/candidates.test.ts @@ -35,6 +35,22 @@ describe("buildCandidatesQuery", () => { ); }); + it("escapes PostgREST search syntax in candidate search terms", () => { + buildCandidatesQuery(mock.client, { q: "python_dev,pro.v2" }); + + expect(mock.chain.or).toHaveBeenCalledWith( + String.raw`full_name.ilike.%python\_dev\,pro\.v2%,username.ilike.%python\_dev\,pro\.v2%,bio.ilike.%python\_dev\,pro\.v2%` + ); + }); + + it("escapes LIKE wildcards and escape characters in candidate search terms", () => { + buildCandidatesQuery(mock.client, { q: String.raw`100%\candidate*` }); + + expect(mock.chain.or).toHaveBeenCalledWith( + String.raw`full_name.ilike.%100\%\\candidate\*%,username.ilike.%100\%\\candidate\*%,bio.ilike.%100\%\\candidate\*%` + ); + }); + it("filters by availability when available=true", () => { buildCandidatesQuery(mock.client, { available: "true" }); @@ -47,6 +63,22 @@ describe("buildCandidatesQuery", () => { expect(mock.chain.or).toHaveBeenCalledWith('skills.cs.{"python"},ai_tools.cs.{"python"}'); }); + it("escapes PostgREST array literal syntax in tag filters", () => { + buildCandidatesQuery(mock.client, { tags: ['python,"admin"}'] }); + + expect(mock.chain.or).toHaveBeenCalledWith( + String.raw`skills.cs.{"python\,\"admin\"\}"},ai_tools.cs.{"python\,\"admin\"\}"}` + ); + }); + + it("escapes opening braces in tag filters", () => { + buildCandidatesQuery(mock.client, { tags: ["{admin}"] }); + + expect(mock.chain.or).toHaveBeenCalledWith( + String.raw`skills.cs.{"\{admin\}"},ai_tools.cs.{"\{admin\}"}` + ); + }); + it("sorts by rate_high descending", () => { buildCandidatesQuery(mock.client, { sort: "rate_high" }); diff --git a/src/lib/queries/candidates.ts b/src/lib/queries/candidates.ts index 767db245..26ebc11f 100644 --- a/src/lib/queries/candidates.ts +++ b/src/lib/queries/candidates.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 buildCandidatesQuery( .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 buildCandidatesQuery( } 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}`); }