diff --git a/src/app/api/users/search/route.test.ts b/src/app/api/users/search/route.test.ts index bc95c7e8..41bcc933 100644 --- a/src/app/api/users/search/route.test.ts +++ b/src/app/api/users/search/route.test.ts @@ -102,6 +102,22 @@ describe("GET /api/users/search", () => { expect(mockIlike).toHaveBeenCalledWith("username", "cho%"); }); + it("escapes PostgREST wildcard and filter syntax in username search", async () => { + vi.mocked(getAuthContext).mockResolvedValue({ + user: { id: "user-1", authMethod: "session" }, + supabase: supabaseClient as any, + }); + + mockLimit.mockResolvedValue({ data: [], error: null }); + + await GET(makeRequest("/api/users/search?q=100%*ai_agent,pro.v2")); + + expect(mockIlike).toHaveBeenCalledWith( + "username", + String.raw`100\%\*ai\_agent\,pro\.v2%` + ); + }); + it("does NOT leak email, full_name, or phone", async () => { vi.mocked(getAuthContext).mockResolvedValue({ user: { id: "user-1", authMethod: "session" }, diff --git a/src/app/api/users/search/route.ts b/src/app/api/users/search/route.ts index 4071a0b1..cedb0179 100644 --- a/src/app/api/users/search/route.ts +++ b/src/app/api/users/search/route.ts @@ -1,5 +1,6 @@ import { NextRequest, NextResponse } from "next/server"; import { getAuthContext } from "@/lib/auth/get-user"; +import { escapePostgrestSearchValue } from "@/lib/security/sanitize"; // GET /api/users/search?q=&limit=10 export async function GET(request: NextRequest) { @@ -21,11 +22,12 @@ export async function GET(request: NextRequest) { } const { supabase } = authContext; + const escapedQuery = escapePostgrestSearchValue(query); const { data: users, error } = await supabase .from("profiles") .select("id, username, avatar_url") - .ilike("username", `${query}%`) + .ilike("username", `${escapedQuery}%`) .limit(limit); if (error) { diff --git a/src/lib/security/sanitize.ts b/src/lib/security/sanitize.ts index 790b3c14..4bcc81be 100644 --- a/src/lib/security/sanitize.ts +++ b/src/lib/security/sanitize.ts @@ -46,8 +46,9 @@ 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}`); }