From 06c840ff0ef21691d227ae6c852c023fbb99b296 Mon Sep 17 00:00:00 2001 From: "groombook-ci[bot]" Date: Sun, 29 Mar 2026 00:42:18 +0000 Subject: [PATCH] =?UTF-8?q?fix(api):=20replace=20lte()=20with=20inArray()?= =?UTF-8?q?=20in=20portal=20queries=20=E2=80=94=20data=20leak?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CRITICAL data leak: portal queries used lte(id, maxId) to fetch related entities, which returned ALL records with ID ≤ maxId — leaking other clients' pets, staff, and invoice line items. Fixed all three occurrences: - pets: lte(pets.id, maxId) → inArray(pets.id, petIds) - staff: lte(staff.id, maxId) → inArray(staff.id, staffIds) - invoiceLineItems: lte(invoiceId, maxId) → inArray(invoiceId, invoiceIds) Also added inArray to @groombook/db re-exports from drizzle-orm. Co-Authored-By: Paperclip --- apps/api/src/routes/portal.ts | 13 +++++++------ packages/db/src/index.ts | 2 +- 2 files changed, 8 insertions(+), 7 deletions(-) diff --git a/apps/api/src/routes/portal.ts b/apps/api/src/routes/portal.ts index 35c97e5..135e129 100644 --- a/apps/api/src/routes/portal.ts +++ b/apps/api/src/routes/portal.ts @@ -1,7 +1,8 @@ import { Hono } from "hono"; import { zValidator } from "@hono/zod-validator"; import { z } from "zod/v3"; -import { and, eq, lte, getDb, appointments, impersonationSessions, waitlistEntries, clients, pets, services, staff, invoices, invoiceLineItems } from "@groombook/db"; +import { and, eq, inArray } from "@groombook/db"; +import { getDb, appointments, impersonationSessions, waitlistEntries, clients, pets, services, staff, invoices, invoiceLineItems } from "@groombook/db"; import type { AppEnv } from "../middleware/rbac.js"; export const portalRouter = new Hono(); @@ -64,11 +65,11 @@ portalRouter.get("/appointments", async (c) => { .where(eq(appointments.clientId, clientId)) .orderBy(appointments.startTime); - const petIds = [...new Set(allAppts.map(a => a.petId).filter(Boolean))]; - const staffIds = [...new Set(allAppts.map(a => a.staffId).filter(Boolean))]; + const petIds = allAppts.map(a => a.petId).filter((id): id is string => id !== null); + const staffIds = allAppts.map(a => a.staffId).filter((id): id is string => id !== null); - const petRows = petIds.length ? await db.select().from(pets).where(lte(pets.id, petIds[petIds.length - 1] || "")) : []; - const staffRows = staffIds.length ? await db.select().from(staff).where(lte(staff.id, staffIds[staffIds.length - 1] || "")) : []; + const petRows = petIds.length ? await db.select().from(pets).where(inArray(pets.id, petIds)) : []; + const staffRows = staffIds.length ? await db.select().from(staff).where(inArray(staff.id, staffIds)) : []; const petMap = Object.fromEntries(petRows.map(p => [p.id, p])); const staffMap = Object.fromEntries(staffRows.map(s => [s.id, s])); @@ -110,7 +111,7 @@ portalRouter.get("/invoices", async (c) => { const clientInvoices = await db.select().from(invoices).where(eq(invoices.clientId, clientId)); const invoiceIds = clientInvoices.map(i => i.id); - const lineItems = invoiceIds.length ? await db.select().from(invoiceLineItems).where(lte(invoiceLineItems.invoiceId, invoiceIds[invoiceIds.length - 1] || "")) : []; + const lineItems = invoiceIds.length ? await db.select().from(invoiceLineItems).where(inArray(invoiceLineItems.invoiceId, invoiceIds)) : []; const itemsByInvoice: Record = {}; for (const li of lineItems) { diff --git a/packages/db/src/index.ts b/packages/db/src/index.ts index 61ec021..5990fd0 100644 --- a/packages/db/src/index.ts +++ b/packages/db/src/index.ts @@ -3,7 +3,7 @@ import postgres from "postgres"; import * as schema from "./schema.js"; export * from "./schema.js"; -export { and, asc, desc, eq, exists, gte, gt, ilike, lt, lte, ne, or, sql } from "drizzle-orm"; +export { and, asc, desc, eq, exists, gte, gt, ilike, inArray, lt, lte, ne, or, sql } from "drizzle-orm"; let _db: ReturnType | null = null;