From 7d71bf45e046874235ac80d04863db8af73c44c1 Mon Sep 17 00:00:00 2001 From: nimkarprachi17 Date: Sun, 14 Jun 2026 18:14:17 +0530 Subject: [PATCH 1/2] feat: standardise PostgREST string escaping across codebase (Closes #1543) --- apps/api/src/routes/interactions.ts | 5 +++-- apps/api/src/routes/scan.ts | 6 +++--- apps/api/src/services/medicineRag.service.ts | 4 ++-- apps/api/src/utils/db.ts | 11 +++++++++++ apps/web/app/[locale]/components/SearchBar.tsx | 5 ++++- apps/web/lib/supabase/utils.ts | 12 ++++++++++++ 6 files changed, 35 insertions(+), 8 deletions(-) create mode 100644 apps/web/lib/supabase/utils.ts diff --git a/apps/api/src/routes/interactions.ts b/apps/api/src/routes/interactions.ts index 7e9a361f8..ee6bcc393 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(); @@ -256,7 +257,7 @@ router.post("/check", async (req: Request, res: Response) => { .from("drug_interactions") .select("*") .or( - `and(drug_a_id.eq.${a},drug_b_id.eq.${b}),and(drug_a_id.eq.${b},drug_b_id.eq.${a})` + `and(drug_a_id.eq.${escapePostgrest(a)},drug_b_id.eq.${escapePostgrest(b)}),and(drug_a_id.eq.${escapePostgrest(b)},drug_b_id.eq.${escapePostgrest(a)})` ) .maybeSingle(); diff --git a/apps/api/src/routes/scan.ts b/apps/api/src/routes/scan.ts index 4231b171e..dd92438b3 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..34be1bec3 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..7dbf9445d 100644 --- a/apps/api/src/utils/db.ts +++ b/apps/api/src/utils/db.ts @@ -7,3 +7,14 @@ 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..c53501717 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..cbf1a9b66 --- /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 From 91ba1c6fafe5fc2b013aa24983ab1c9efe3263b1 Mon Sep 17 00:00:00 2001 From: nimkarprachi17 Date: Mon, 15 Jun 2026 09:38:08 +0530 Subject: [PATCH 2/2] fix: correct PostgREST quoting syntax in escapePostgrest utility --- apps/api/src/routes/interactions.ts | 4 ++-- apps/api/src/routes/scan.ts | 4 ++-- apps/api/src/services/medicineRag.service.ts | 2 +- apps/api/src/utils/db.ts | 6 +----- apps/web/app/[locale]/components/SearchBar.tsx | 2 +- apps/web/lib/supabase/utils.ts | 4 ++-- 6 files changed, 9 insertions(+), 13 deletions(-) diff --git a/apps/api/src/routes/interactions.ts b/apps/api/src/routes/interactions.ts index ee6bcc393..d3f0ff18c 100644 --- a/apps/api/src/routes/interactions.ts +++ b/apps/api/src/routes/interactions.ts @@ -112,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.%${escapePostgrest(escaped)}%,generic_name.ilike.%${escapePostgrest(escaped)}%` + `id.eq.${escaped},brand_name.ilike."%${escapePostgrest(escaped)}%",generic_name.ilike."%${escapePostgrest(escaped)}%"` ) .limit(1) .maybeSingle(); @@ -257,7 +257,7 @@ router.post("/check", async (req: Request, res: Response) => { .from("drug_interactions") .select("*") .or( - `and(drug_a_id.eq.${escapePostgrest(a)},drug_b_id.eq.${escapePostgrest(b)}),and(drug_a_id.eq.${escapePostgrest(b)},drug_b_id.eq.${escapePostgrest(a)})` + `and(drug_a_id.eq.${a},drug_b_id.eq.${b}),and(drug_a_id.eq.${b},drug_b_id.eq.${a})` ) .maybeSingle(); diff --git a/apps/api/src/routes/scan.ts b/apps/api/src/routes/scan.ts index dd92438b3..dba4f2f60 100644 --- a/apps/api/src/routes/scan.ts +++ b/apps/api/src/routes/scan.ts @@ -507,7 +507,7 @@ router.post("/extract", uploadRateLimiter, validateUploadSize, (req: Request, re "composition, mrp, jan_aushadhi_price" ) .or( - `brand_name.ilike.%${escapePostgrest(matchedName!)}%,generic_name.ilike.%${escapePostgrest(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.%${escapePostgrest(brandName)}%,generic_name.ilike.%${escapePostgrest(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 34be1bec3..48ad61462 100644 --- a/apps/api/src/services/medicineRag.service.ts +++ b/apps/api/src/services/medicineRag.service.ts @@ -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.${escapePostgrest(pattern)},brand_name.ilike.${escapePostgrest(pattern)},composition.ilike.${escapePostgrest(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 7dbf9445d..45dc5b576 100644 --- a/apps/api/src/utils/db.ts +++ b/apps/api/src/utils/db.ts @@ -12,9 +12,5 @@ export function escapeIlike(word: string): string { * Wraps in double quotes to prevent comma injection. */ export function escapePostgrest(val: string): string { - return `"${val - .replace(/\\/g, "\\\\") - .replace(/%/g, "\\%") - .replace(/_/g, "\\_") - .replace(/"/g, '\\"')}"`; + 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 c53501717..773bea29c 100644 --- a/apps/web/app/[locale]/components/SearchBar.tsx +++ b/apps/web/app/[locale]/components/SearchBar.tsx @@ -163,7 +163,7 @@ export default function SearchBar({ dark = false, onSearchChange }: SearchBarPro .from("medicines") .select("brand_name, batch_number") .or( - `brand_name.ilike.%${escapePostgrest(trimmed)}%,batch_number.ilike.%${escapePostgrest(trimmed)}%` + `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 index cbf1a9b66..17e3f94d7 100644 --- a/apps/web/lib/supabase/utils.ts +++ b/apps/web/lib/supabase/utils.ts @@ -4,9 +4,9 @@ * PostgreSQL ILIKE wildcard characters (% and _). */ export function escapePostgrest(val: string): string { - return `"${val + return val .replace(/\\/g, "\\\\") .replace(/%/g, "\\%") .replace(/_/g, "\\_") - .replace(/"/g, '\\"')}"`; + .replace(/"/g, '""'); } \ No newline at end of file