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/agents.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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%`
);
});
Comment thread
greptile-apps[bot] marked this conversation as resolved.

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

Expand All @@ -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\"\}"}`
);
});
Comment thread
greptile-apps[bot] marked this conversation as resolved.

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

Expand Down
10 changes: 8 additions & 2 deletions src/lib/queries/agents.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 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}%`
);
}

Expand All @@ -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) {
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}`);
}
Loading