diff --git a/.jules/sentinel.md b/.jules/sentinel.md new file mode 100644 index 00000000..bedc8d0e --- /dev/null +++ b/.jules/sentinel.md @@ -0,0 +1,11 @@ +# Sentinel Security Journal + +## 2025-05-14 - [Vulnerability] Mass Assignment in Drizzle Server Actions +**Vulnerability:** Use of the spread operator (`...noteData`) in Drizzle `.set()` or `.values()` calls allowed client-supplied input to overwrite protected fields like `userId`. +**Learning:** Even if the `where` clause checks for the correct `userId`, mass assignment can still allow a user to "transfer" their records to another user by changing the `userId` in the payload. +**Prevention:** Always explicitly list mutable columns in database operations within server actions. + +## 2025-05-14 - [Vulnerability] Wildcard Enumeration in ILIKE Queries +**Vulnerability:** Unsanitized user input in `ILIKE` patterns (`%${query}%`) allowed authenticated users to enumerate records (e.g., all user emails) by using `%` or `_` wildcards. +**Learning:** Drizzle's `ilike` operator does not automatically escape wildcards. Postgres requires an explicit `ESCAPE` clause to treat these characters as literals. +**Prevention:** Sanitize user input by escaping `%`, `_`, and `\` and using `sql`${column} ILIKE ${pattern} ESCAPE '\\'` template literals. diff --git a/lib/actions/calendar.ts b/lib/actions/calendar.ts index d2e4dcf9..d3d9c81e 100644 --- a/lib/actions/calendar.ts +++ b/lib/actions/calendar.ts @@ -73,7 +73,15 @@ export async function saveNote(noteData: NewCalendarNote | CalendarNote): Promis try { const [updatedNote] = await db .update(calendarNotes) - .set({ ...noteData, updatedAt: new Date() }) + .set({ + content: noteData.content, + date: noteData.date, + chatId: noteData.chatId, + locationTags: noteData.locationTags, + userTags: noteData.userTags, + mapFeatureId: noteData.mapFeatureId, + updatedAt: new Date() + }) .where(and(eq(calendarNotes.id, noteData.id), eq(calendarNotes.userId, userId))) .returning(); return updatedNote; @@ -86,7 +94,15 @@ export async function saveNote(noteData: NewCalendarNote | CalendarNote): Promis try { const [newNote] = await db .insert(calendarNotes) - .values({ ...noteData, userId }) + .values({ + userId, + content: noteData.content, + date: noteData.date, + chatId: noteData.chatId, + locationTags: noteData.locationTags, + userTags: noteData.userTags, + mapFeatureId: noteData.mapFeatureId, + }) .returning(); if (newNote && newNote.chatId) { diff --git a/lib/actions/users.ts b/lib/actions/users.ts index 6fba7f8b..5bce6fff 100644 --- a/lib/actions/users.ts +++ b/lib/actions/users.ts @@ -3,7 +3,7 @@ import { revalidatePath, unstable_noStore as noStore } from 'next/cache'; import { db } from '@/lib/db'; import { users } from '@/lib/db/schema'; -import { eq, ilike } from 'drizzle-orm'; +import { eq, ilike, sql } from 'drizzle-orm'; import { getCurrentUserIdOnServer } from '@/lib/auth/get-current-user'; export type UserRole = "admin" | "editor" | "viewer"; @@ -187,7 +187,8 @@ export async function saveSelectedModel(model: string): Promise<{ success: boole */ export async function searchUsers(query: string) { noStore(); - if (!query) return []; + // Enforce length limit and handle empty query to prevent DoS/enumeration + if (!query || query.length > 100) return []; const userId = await getCurrentUserIdOnServer(); if (!userId) { @@ -195,12 +196,15 @@ export async function searchUsers(query: string) { } try { + // Escape special ILIKE characters (%, _, \) to prevent wildcard enumeration + const escapedQuery = query.replace(/[%_\\]/g, '\\$&'); + const result = await db.select({ id: users.id, email: users.email, }) .from(users) - .where(ilike(users.email, `%${query}%`)) + .where(sql`${users.email} ILIKE ${'%' + escapedQuery + '%'} ESCAPE '\\'`) .limit(10); return result;