diff --git a/apps/api/src/routes/interactions.ts b/apps/api/src/routes/interactions.ts index 7e9a361f8..d3f0ff18c 100644 --- a/apps/api/src/routes/interactions.ts +++ b/apps/api/src/routes/interactions.ts @@ -3,6 +3,7 @@ import { z } from "zod"; import { supabase, dbConfig } from "../db/client"; import logger from "../utils/logger"; import { escapeIlike } from "../utils/db"; +import { escapePostgrest } from "../utils/db"; const router = Router(); @@ -111,7 +112,7 @@ async function resolveToGeneric(input: string): Promise<{ input: string; generic .from("medicines") .select("brand_name, generic_name") .or( - `id.eq.${escaped},brand_name.ilike.%${escaped}%,generic_name.ilike.%${escaped}%` + `id.eq.${escaped},brand_name.ilike."%${escapePostgrest(escaped)}%",generic_name.ilike."%${escapePostgrest(escaped)}%"` ) .limit(1) .maybeSingle(); diff --git a/apps/api/src/routes/scan.ts b/apps/api/src/routes/scan.ts index 4231b171e..dba4f2f60 100644 --- a/apps/api/src/routes/scan.ts +++ b/apps/api/src/routes/scan.ts @@ -8,7 +8,7 @@ import { supabase } from "../db/client"; import { getMlServiceUrl, MISSING_ML_SERVICE_URL_MESSAGE } from "../config/mlService"; import { validateUploadSize } from "../middleware/uploadSizeValidator"; import { uploadRateLimiter } from "../middleware/uploadRateLimit"; - +import { escapePostgrest } from "../utils/db"; import { escapeIlike } from "../utils/db"; const router = Router(); @@ -507,7 +507,7 @@ router.post("/extract", uploadRateLimiter, validateUploadSize, (req: Request, re "composition, mrp, jan_aushadhi_price" ) .or( - `brand_name.ilike.%${escapeIlike(matchedName)}%,generic_name.ilike.%${escapeIlike(matchedName)}%` + `brand_name.ilike."%${escapePostgrest(matchedName!)}%",generic_name.ilike."%${escapePostgrest(matchedName!)}%"` ) .limit(1) .maybeSingle(); @@ -701,7 +701,7 @@ router.post("/verify-brand", async (req: Request, res: Response) => { "brand_name, generic_name, manufacturer, batch_number, expiry_date, cdsco_approval_status, is_counterfeit_alert" ) .or( - `brand_name.ilike.%${escapeIlike(brandName)}%,generic_name.ilike.%${escapeIlike(brandName)}%` + `brand_name.ilike."%${escapePostgrest(brandName)}%",generic_name.ilike."%${escapePostgrest(brandName)}%"` ) .limit(1) .maybeSingle(); diff --git a/apps/api/src/services/medicineRag.service.ts b/apps/api/src/services/medicineRag.service.ts index 9e70ea1ea..48ad61462 100644 --- a/apps/api/src/services/medicineRag.service.ts +++ b/apps/api/src/services/medicineRag.service.ts @@ -1,7 +1,7 @@ import { z } from "zod"; import { anonSupabase } from "../db/supabase"; import logger from "../utils/logger"; -import { escapeIlike } from "../utils/db"; +import { escapeIlike, escapePostgrest } from "../utils/db"; // ── Constants ──────────────────────────────────────────────────────────────── @@ -277,7 +277,7 @@ export async function retrieveRelevantMedicines( "id, brand_name, generic_name, manufacturer, composition, strength, dosage_form, schedule, mrp, jan_aushadhi_price" ) .or( - `generic_name.ilike.${pattern},brand_name.ilike.${pattern},composition.ilike.${pattern}` + `generic_name.ilike."${escapePostgrest(pattern)}",brand_name.ilike."${escapePostgrest(pattern)}",composition.ilike."${escapePostgrest(pattern)}"` ) .limit(limit); diff --git a/apps/api/src/utils/db.ts b/apps/api/src/utils/db.ts index d939cf22f..45dc5b576 100644 --- a/apps/api/src/utils/db.ts +++ b/apps/api/src/utils/db.ts @@ -7,3 +7,10 @@ export function escapeIlike(word: string): string { return word.replace(/\\/g, "\\\\").replace(/%/g, "\\%").replace(/_/g, "\\_"); } +/** + * Escapes a value for safe use in PostgREST .or() filters. + * Wraps in double quotes to prevent comma injection. + */ +export function escapePostgrest(val: string): string { + return val.replace(/\\/g, "\\\\").replace(/%/g, "\\%").replace(/_/g, "\\_").replace(/"/g, '""'); +} diff --git a/apps/web/app/[locale]/components/SearchBar.tsx b/apps/web/app/[locale]/components/SearchBar.tsx index 8479252c3..773bea29c 100644 --- a/apps/web/app/[locale]/components/SearchBar.tsx +++ b/apps/web/app/[locale]/components/SearchBar.tsx @@ -5,6 +5,7 @@ import { supabase } from "@/lib/supabase"; import { useTranslations } from "next-intl"; import { fuzzyMatchBrand } from "@/lib/api"; import SearchSuggestions, { HistoryItem } from "@/components/SearchSuggestions"; +import { escapePostgrest } from "@/lib/supabase/utils"; /** Maximum number of suggestions shown at once */ const MAX_SUGGESTIONS = 8; @@ -161,7 +162,9 @@ export default function SearchBar({ dark = false, onSearchChange }: SearchBarPro const response = await supabase .from("medicines") .select("brand_name, batch_number") - .or(`brand_name.ilike.%${trimmed}%,batch_number.ilike.%${trimmed}%`) + .or( + `brand_name.ilike."%${escapePostgrest(trimmed)}%",batch_number.ilike."%${escapePostgrest(trimmed)}%"` + ) .abortSignal(controller.signal) .limit(MAX_SUGGESTIONS); diff --git a/apps/web/lib/supabase/utils.ts b/apps/web/lib/supabase/utils.ts new file mode 100644 index 000000000..17e3f94d7 --- /dev/null +++ b/apps/web/lib/supabase/utils.ts @@ -0,0 +1,12 @@ +/** + * Escapes a value for safe use in PostgREST .or() and .ilike() filters. + * Wraps in double quotes to prevent comma injection and escapes + * PostgreSQL ILIKE wildcard characters (% and _). + */ +export function escapePostgrest(val: string): string { + return val + .replace(/\\/g, "\\\\") + .replace(/%/g, "\\%") + .replace(/_/g, "\\_") + .replace(/"/g, '""'); +} \ No newline at end of file