Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .jules/sentinel.md
Original file line number Diff line number Diff line change
@@ -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.
20 changes: 18 additions & 2 deletions lib/actions/calendar.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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) {
Expand Down
10 changes: 7 additions & 3 deletions lib/actions/users.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -187,20 +187,24 @@ 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) {
throw new Error('Unauthorized');
}

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;
Expand Down