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
32 changes: 32 additions & 0 deletions src/lib/queries/candidates.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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" });

Expand All @@ -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" });

Expand Down
10 changes: 8 additions & 2 deletions src/lib/queries/candidates.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
import { SupabaseClient } from "@supabase/supabase-js";
import {
escapePostgrestArrayLiteralValue,
escapePostgrestSearchValue,
} from "@/lib/security/sanitize";

const MAX_PAGE = 100_000;

Expand Down Expand Up @@ -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}%`
);
}

Expand All @@ -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) {
Expand Down
14 changes: 12 additions & 2 deletions src/lib/security/sanitize.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}`);
}
Comment on lines +61 to 63
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 LIKE-wildcard escaping incorrectly applied to array-literal values

escapePostgrestArrayLiteralValue delegates first to escapePostgrestSearchValue, which escapes %, _, and * as LIKE wildcard characters. Those characters have no special meaning inside a PostgreSQL double-quoted array element used with the cs (contains) operator — PostgreSQL stores them literally and matches them by equality. Escaping them inserts a literal backslash into the stored comparison value, so a tag named 100%off would be queried as 100\%off and silently fail to match any row. Only " and \ need to be escaped for the PostgreSQL array literal layer; ,, ., (, ), %, _, * are PostgREST filter-syntax and LIKE concerns that don't apply to the cs value.

Comment on lines +61 to 63
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 escapePostgrestArrayLiteralValue has no direct unit tests in sanitize.test.ts

The new function is exported from sanitize.ts but sanitize.test.ts only imports and tests escapePostgrestSearchValue, sanitizeUrlParam, and sanitizeSearchParams. Coverage currently exists only through the integration-level candidates.test.ts. Adding a unit test alongside the existing escapePostgrestSearchValue block (e.g., covering backslash, quote, and brace combinations) would make it easier to iterate on the function in isolation.

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!

Loading