Skip to content

Commit 5d9e413

Browse files
rissrice2105-agentCodex Agent
andauthored
Escape marketplace search filters (#394)
Co-authored-by: Codex Agent <codex-agent@example.com>
1 parent 1146cb6 commit 5d9e413

10 files changed

Lines changed: 114 additions & 11 deletions

File tree

src/app/api/affiliates/offers/route.test.ts

Lines changed: 22 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -98,11 +98,30 @@ describe("GET /api/affiliates/offers", () => {
9898
});
9999

100100
it("filters by search query (#21)", async () => {
101-
mockFrom.mockReturnValue(chainable([], null, 0));
101+
const orSpy = vi.fn();
102+
const queryChain: Record<string, any> = {};
103+
const chainHandler: ProxyHandler<any> = {
104+
get(_target, prop) {
105+
if (prop === "then") return undefined;
106+
if (prop === "data") return [];
107+
if (prop === "error") return null;
108+
if (prop === "count") return 0;
109+
if (prop === "or") {
110+
return (...args: any[]) => {
111+
orSpy(...args);
112+
return new Proxy(queryChain, chainHandler);
113+
};
114+
}
115+
return (..._args: any[]) => new Proxy(queryChain, chainHandler);
116+
},
117+
};
118+
mockFrom.mockReturnValue(new Proxy(queryChain, chainHandler));
102119

103-
const res = await GET(makeRequest({ q: "test search" }));
120+
const res = await GET(makeRequest({ q: "100%_offer,(v1.2)" }));
104121
expect(res.status).toBe(200);
105-
expect(mockFrom).toHaveBeenCalled();
122+
expect(orSpy).toHaveBeenCalledWith(
123+
"title.ilike.%100\\%\\_offer\\,\\(v1\\.2\\)%,description.ilike.%100\\%\\_offer\\,\\(v1\\.2\\)%"
124+
);
106125
});
107126

108127
it("hides product_url from unauthenticated users (#20)", async () => {

src/app/api/affiliates/offers/route.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from "next/server";
22
import { getAuthContext } from "@/lib/auth/get-user";
33
import { createServiceClient } from "@/lib/supabase/service";
44
import { checkRateLimit, rateLimitExceeded, getRateLimitIdentifier } from "@/lib/rate-limit";
5+
import { escapePostgrestSearchValue } from "@/lib/security/sanitize";
56

67
// eslint-disable-next-line @typescript-eslint/no-explicit-any
78
type AnySupabase = any;
@@ -64,7 +65,8 @@ export async function GET(request: NextRequest) {
6465
if (slugFilter) {
6566
query = query.eq("slug", slugFilter);
6667
} else if (search) {
67-
query = query.or(`title.ilike.%${search}%,description.ilike.%${search}%`);
68+
const safeSearch = escapePostgrestSearchValue(search);
69+
query = query.or(`title.ilike.%${safeSearch}%,description.ilike.%${safeSearch}%`);
6870
}
6971

7072
// Sort

src/app/api/mcp/route.test.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,32 @@ describe("GET /api/mcp", () => {
8989
expect(json.page).toBe(1);
9090
});
9191

92+
it("escapes search text before building PostgREST filters", async () => {
93+
const orSpy = vi.fn(() => ({
94+
order: () => ({
95+
range: () => Promise.resolve({ data: [], count: 0, error: null }),
96+
}),
97+
}));
98+
99+
mockFrom.mockReturnValue({
100+
select: () => ({
101+
eq: () => ({
102+
or: orSpy,
103+
order: () => ({
104+
range: () => Promise.resolve({ data: [], count: 0, error: null }),
105+
}),
106+
}),
107+
}),
108+
});
109+
110+
const response = await GET(makeGetRequest({ search: "100%_mcp,(v1.2)" }));
111+
112+
expect(response.status).toBe(200);
113+
expect(orSpy).toHaveBeenCalledWith(
114+
"title.ilike.%100\\%\\_mcp\\,\\(v1\\.2\\)%,description.ilike.%100\\%\\_mcp\\,\\(v1\\.2\\)%,tagline.ilike.%100\\%\\_mcp\\,\\(v1\\.2\\)%"
115+
);
116+
});
117+
92118
it("returns empty listings when none exist", async () => {
93119
mockFrom.mockReturnValue({
94120
select: () => ({

src/app/api/mcp/route.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { getAuthContext } from "@/lib/auth/get-user";
44
import { createServiceClient } from "@/lib/supabase/service";
55
import { mcpListingSchema, slugify } from "@/lib/mcp/validation";
66
import { combinedScan, MCP_SCANNER_VERSION } from "@/lib/mcp/security-scan";
7-
import { sanitizeSearchParams } from "@/lib/security/sanitize";
7+
import { escapePostgrestSearchValue, sanitizeSearchParams } from "@/lib/security/sanitize";
88

99
const MAX_PAGE = 100_000;
1010

@@ -40,7 +40,8 @@ export async function GET(request: NextRequest) {
4040
.eq("status", "active");
4141

4242
if (search) {
43-
query = query.or(`title.ilike.%${search}%,description.ilike.%${search}%,tagline.ilike.%${search}%`);
43+
const safeSearch = escapePostgrestSearchValue(search);
44+
query = query.or(`title.ilike.%${safeSearch}%,description.ilike.%${safeSearch}%,tagline.ilike.%${safeSearch}%`);
4445
}
4546

4647
if (category) {

src/app/api/search/route.test.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -327,6 +327,17 @@ describe("GET /api/search", () => {
327327
expect(orArg).toContain("my\\_var");
328328
});
329329

330+
it("escapes PostgREST filter punctuation in search query", async () => {
331+
const chain = chainResult({ data: [], error: null, count: 0 });
332+
mockFrom.mockReturnValue(chain);
333+
334+
await GET(makeRequest({ q: "foo,(v1.2)", type: "posts" }));
335+
336+
expect(chain.or).toHaveBeenCalled();
337+
const orArg = chain.or.mock.calls[0][0] as string;
338+
expect(orArg).toBe("content.ilike.%foo\\,\\(v1\\.2\\)%");
339+
});
340+
330341
// ── Empty results ─────────────────────────────────────────────
331342

332343
it("returns proper empty structure for type=all", async () => {

src/app/api/search/route.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { NextRequest, NextResponse } from "next/server";
22
import { createClient } from "@/lib/supabase/server";
3+
import { escapePostgrestSearchValue } from "@/lib/security/sanitize";
34

45
type SearchType = "gigs" | "agents" | "posts" | "all";
56

@@ -40,8 +41,7 @@ export async function GET(request: NextRequest) {
4041
const supabase = await createClient();
4142
const offset = (page - 1) * limit;
4243

43-
// Escape special ilike characters
44-
const escaped = query.replace(/%/g, "\\%").replace(/_/g, "\\_");
44+
const escaped = escapePostgrestSearchValue(query);
4545
const pattern = `%${escaped}%`;
4646

4747
const results: Record<string, unknown> = {};

src/app/api/skills/route.test.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,32 @@ describe("GET /api/skills", () => {
123123
expect(json.page).toBe(1);
124124
});
125125

126+
it("escapes search text before building PostgREST filters", async () => {
127+
const orSpy = vi.fn(() => ({
128+
order: () => ({
129+
range: () => Promise.resolve({ data: [], count: 0, error: null }),
130+
}),
131+
}));
132+
133+
mockFrom.mockReturnValue({
134+
select: () => ({
135+
eq: () => ({
136+
or: orSpy,
137+
order: () => ({
138+
range: () => Promise.resolve({ data: [], count: 0, error: null }),
139+
}),
140+
}),
141+
}),
142+
});
143+
144+
const response = await GET(makeGetRequest({ search: "100%_mcp,(v1.2)" }));
145+
146+
expect(response.status).toBe(200);
147+
expect(orSpy).toHaveBeenCalledWith(
148+
"title.ilike.%100\\%\\_mcp\\,\\(v1\\.2\\)%,description.ilike.%100\\%\\_mcp\\,\\(v1\\.2\\)%,tagline.ilike.%100\\%\\_mcp\\,\\(v1\\.2\\)%"
149+
);
150+
});
151+
126152
it("clamps invalid page values to the first page", async () => {
127153
const range = vi.fn(() =>
128154
Promise.resolve({ data: [], count: 0, error: null })

src/app/api/skills/route.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import { skillListingSchema } from "@/lib/skills/validation";
66
import { slugify } from "@/lib/skills/validation";
77
import { importSkillFromUrl } from "@/lib/skills/url-import";
88
import { isScanAcceptable } from "@/lib/skills/security-scan";
9-
import { sanitizeSearchParams } from "@/lib/security/sanitize";
9+
import { escapePostgrestSearchValue, sanitizeSearchParams } from "@/lib/security/sanitize";
1010

1111
const MAX_PAGE = 100_000;
1212

@@ -56,7 +56,8 @@ export async function GET(request: NextRequest) {
5656
.eq("status", "active");
5757

5858
if (search) {
59-
query = query.or(`title.ilike.%${search}%,description.ilike.%${search}%,tagline.ilike.%${search}%`);
59+
const safeSearch = escapePostgrestSearchValue(search);
60+
query = query.or(`title.ilike.%${safeSearch}%,description.ilike.%${safeSearch}%,tagline.ilike.%${safeSearch}%`);
6061
}
6162

6263
if (category) {

src/lib/security/sanitize.test.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { describe, it, expect } from "vitest";
2-
import { sanitizeUrlParam, sanitizeSearchParams } from "./sanitize";
2+
import { escapePostgrestSearchValue, sanitizeUrlParam, sanitizeSearchParams } from "./sanitize";
33

44
describe("sanitizeUrlParam", () => {
55
it("should return empty string for null/undefined input", () => {
@@ -51,3 +51,11 @@ describe("sanitizeSearchParams", () => {
5151
expect(sanitizeSearchParams(url, "missing")).toBe("");
5252
});
5353
});
54+
55+
describe("escapePostgrestSearchValue", () => {
56+
it("escapes LIKE wildcards and PostgREST filter punctuation", () => {
57+
expect(escapePostgrestSearchValue("100%_match,(v1.2)")).toBe(
58+
"100\\%\\_match\\,\\(v1\\.2\\)"
59+
);
60+
});
61+
});

src/lib/security/sanitize.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,3 +42,12 @@ export function sanitizeSearchParams(
4242
const value = url.searchParams.get(param);
4343
return sanitizeUrlParam(value);
4444
}
45+
46+
/**
47+
* Escape user text before interpolating it into a PostgREST filter string.
48+
* PostgREST uses punctuation such as commas, periods, and parentheses as
49+
* filter syntax, while SQL LIKE treats % and _ as wildcards.
50+
*/
51+
export function escapePostgrestSearchValue(value: string): string {
52+
return value.replace(/[\\%_,().]/g, (char) => `\\${char}`);
53+
}

0 commit comments

Comments
 (0)