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
9 changes: 9 additions & 0 deletions .jules/sentinel.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
## 2025-05-14 - [IDOR in Server Actions]
**Vulnerability:** Several server actions in `lib/actions/chat.ts` accepted a `userId` parameter from the client and used it directly for database queries without verification, allowing any authenticated user to read or modify data belonging to other users.
**Learning:** Next.js Server Actions are public API endpoints. Parameters passed from the client cannot be trusted for authorization.
**Prevention:** Always retrieve the `userId` from a secure session (e.g., via `getCurrentUserIdOnServer()`) inside the server action and ignore or verify any `userId` passed as an argument.

## 2025-05-14 - [Wildcard Enumeration in SQL]
**Vulnerability:** The `searchUsers` function used unescaped user input in an `ilike` query, allowing users to enumerate the entire user table using wildcard characters like `%` or `_`.
**Learning:** Wildcard characters in `LIKE`/`ILIKE` queries can be abused to bypass filters or extract data if not properly escaped.
**Prevention:** Always escape `%`, `_`, and `\` in user input before using it in a `LIKE`/`ILIKE` query. Also, enforce length limits on search queries.
Comment on lines +1 to +9
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Minor markdown lint issues flagged by markdownlint.

The file is missing a top-level heading (#) and blank lines around ## headings, as flagged by static analysis (MD041, MD022). Consider adding a title and blank lines for proper formatting.

📝 Proposed fix
+# Sentinel Security Notes
+
 ## 2025-05-14 - [IDOR in Server Actions]
+
 **Vulnerability:** Several server actions in `lib/actions/chat.ts` accepted a `userId` parameter from the client and used it directly for database queries without verification, allowing any authenticated user to read or modify data belonging to other users.
 **Learning:** Next.js Server Actions are public API endpoints. Parameters passed from the client cannot be trusted for authorization.
 **Prevention:** Always retrieve the `userId` from a secure session (e.g., via `getCurrentUserIdOnServer()`) inside the server action and ignore or verify any `userId` passed as an argument.
 
 ## 2025-05-14 - [Wildcard Enumeration in SQL]
+
 **Vulnerability:** The `searchUsers` function used unescaped user input in an `ilike` query, allowing users to enumerate the entire user table using wildcard characters like `%` or `_`.
 **Learning:** Wildcard characters in `LIKE`/`ILIKE` queries can be abused to bypass filters or extract data if not properly escaped.
 **Prevention:** Always escape `%`, `_`, and `\` in user input before using it in a `LIKE`/`ILIKE` query. Also, enforce length limits on search queries.
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
## 2025-05-14 - [IDOR in Server Actions]
**Vulnerability:** Several server actions in `lib/actions/chat.ts` accepted a `userId` parameter from the client and used it directly for database queries without verification, allowing any authenticated user to read or modify data belonging to other users.
**Learning:** Next.js Server Actions are public API endpoints. Parameters passed from the client cannot be trusted for authorization.
**Prevention:** Always retrieve the `userId` from a secure session (e.g., via `getCurrentUserIdOnServer()`) inside the server action and ignore or verify any `userId` passed as an argument.
## 2025-05-14 - [Wildcard Enumeration in SQL]
**Vulnerability:** The `searchUsers` function used unescaped user input in an `ilike` query, allowing users to enumerate the entire user table using wildcard characters like `%` or `_`.
**Learning:** Wildcard characters in `LIKE`/`ILIKE` queries can be abused to bypass filters or extract data if not properly escaped.
**Prevention:** Always escape `%`, `_`, and `\` in user input before using it in a `LIKE`/`ILIKE` query. Also, enforce length limits on search queries.
# Sentinel Security Notes
## 2025-05-14 - [IDOR in Server Actions]
**Vulnerability:** Several server actions in `lib/actions/chat.ts` accepted a `userId` parameter from the client and used it directly for database queries without verification, allowing any authenticated user to read or modify data belonging to other users.
**Learning:** Next.js Server Actions are public API endpoints. Parameters passed from the client cannot be trusted for authorization.
**Prevention:** Always retrieve the `userId` from a secure session (e.g., via `getCurrentUserIdOnServer()`) inside the server action and ignore or verify any `userId` passed as an argument.
## 2025-05-14 - [Wildcard Enumeration in SQL]
**Vulnerability:** The `searchUsers` function used unescaped user input in an `ilike` query, allowing users to enumerate the entire user table using wildcard characters like `%` or `_`.
**Learning:** Wildcard characters in `LIKE`/`ILIKE` queries can be abused to bypass filters or extract data if not properly escaped.
**Prevention:** Always escape `%`, `_`, and `\` in user input before using it in a `LIKE`/`ILIKE` query. Also, enforce length limits on search queries.
🧰 Tools
🪛 markdownlint-cli2 (0.20.0)

[warning] 1-1: Headings should be surrounded by blank lines
Expected: 1; Actual: 0; Below

(MD022, blanks-around-headings)


[warning] 1-1: First line in a file should be a top-level heading

(MD041, first-line-heading, first-line-h1)


[warning] 6-6: Headings should be surrounded by blank lines
Expected: 1; Actual: 0; Below

(MD022, blanks-around-headings)

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In @.jules/sentinel.md around lines 1 - 9, Add a top-level title line (e.g., "#
Sentinel Vulnerability Log") at the top of the file and ensure there is a blank
line before each secondary heading such as "## 2025-05-14 - [IDOR in Server
Actions]" and "## 2025-05-14 - [Wildcard Enumeration in SQL]" to satisfy
markdownlint rules MD041 and MD022; update the file so headings have surrounding
blank lines and the document begins with a single H1 heading.

94 changes: 72 additions & 22 deletions lib/actions/chat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,28 +20,39 @@ import { users } from '@/lib/db/schema'
import { eq } from 'drizzle-orm'
import { getCurrentUserIdOnServer } from '@/lib/auth/get-current-user'

/**
* Retrieves chats for the authenticated user.
* Protected against IDOR by using session-based userId.
*/
export async function getChats(userId?: string | null): Promise<DrizzleChat[]> {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial

🧩 Analysis chain

🏁 Script executed:

# First, let's examine the lib/actions/chat.ts file to see the function signatures and implementations
cat -n lib/actions/chat.ts | head -250

Repository: QueueLab/QCX

Length of output: 8977


🏁 Script executed:

# Search for all call sites of these functions in the codebase
rg -n 'getChats\(|getChat\(|clearChats\(|saveChat\(|saveSystemPrompt\(|getSystemPrompt\(' --type=ts --type=tsx -C 2 -g '!lib/actions/chat.ts'

Repository: QueueLab/QCX

Length of output: 83


🏁 Script executed:

# Also check the specific file mentions in the review: app/actions.tsx line 658 and components/history-list.tsx
cat -n app/actions.tsx | sed -n '650,670p'

Repository: QueueLab/QCX

Length of output: 686


🏁 Script executed:

cat -n components/history-list.tsx | head -50

Repository: QueueLab/QCX

Length of output: 1900


Consider deprecating unused userId parameters or removing them in a follow-up.

The functions getChats, getChat, clearChats, saveChat, saveSystemPrompt, and getSystemPrompt accept userId parameters that are now completely ignored—they use getCurrentUserIdOnServer() instead for IDOR protection. Some already include comments acknowledging they're "kept for backward compatibility but ignored." However, call sites like app/actions.tsx (line 658) and components/history-list.tsx (line 12) still pass values, creating confusion about whether the parameters matter.

Add explicit @deprecated JSDoc tags to these parameters or remove them entirely in a follow-up refactor to eliminate this confusion.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@lib/actions/chat.ts` at line 27, Add explicit `@deprecated` JSDoc tags to the
unused userId parameters on the functions getChats, getChat, clearChats,
saveChat, saveSystemPrompt, and getSystemPrompt to make it clear they are
ignored; update each function's declaration comment to include something like
"@deprecated userId parameter is ignored — getCurrentUserIdOnServer() is used
for IDOR protection" and ensure the JSDoc is placed immediately above the
function so IDEs and linters surface the deprecation; keep the parameter in
place for backward compatibility but mark it deprecated so callers know it has
no effect and can be removed in a follow-up.

if (!userId) {
console.warn('getChats called without userId, returning empty array.')
return []
const currentUserId = await getCurrentUserIdOnServer();
if (!currentUserId) {
console.warn('getChats: User not authenticated');
return [];
}

try {
const { chats } = await dbGetChatsPage(userId, 20, 0)
const { chats } = await dbGetChatsPage(currentUserId, 20, 0)
return chats
} catch (error) {
console.error('Error fetching chats from DB:', error)
return []
}
}

/**
* Retrieves a specific chat.
* Protected against IDOR by using session-based userId and verifying access.
*/
export async function getChat(id: string, userId: string): Promise<DrizzleChat | null> {
if (!userId) {
console.warn('getChat called without userId.')
const currentUserId = await getCurrentUserIdOnServer();
if (!currentUserId) {
console.warn('getChat: User not authenticated');
return null;
}

try {
const chat = await dbGetChat(id, userId)
const chat = await dbGetChat(id, currentUserId)
Comment on lines 47 to +55

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

getChat still accepts a userId argument but ignores it. Unlike clearChats/saveSystemPrompt/getSystemPrompt, there’s no comment indicating the parameter is deprecated/ignored. This creates an easy footgun for callers who think they’re scoping access by passing a specific userId (and it also makes the API inconsistent within this file).

Suggestion

Make the intent explicit and reduce misuse risk by either (a) removing the userId parameter (preferred if you can do a small breaking change) or (b) marking it as deprecated/ignored and renaming to _userId.

Example (non-breaking):

// TODO: remove `userId` parameter in next major; ignored in favor of session userId
export async function getChat(id: string, _userId: string): Promise<DrizzleChat | null> {
  const currentUserId = await getCurrentUserIdOnServer();
  if (!currentUserId) return null;
  return dbGetChat(id, currentUserId);
}

Reply with "@CharlieHelps yes please" if you'd like me to add a commit with this change.

return chat
} catch (error) {
console.error(`Error fetching chat ${id} from DB:`, error)
Expand All @@ -51,27 +62,45 @@ export async function getChat(id: string, userId: string): Promise<DrizzleChat |

/**
* Retrieves all messages for a specific chat.
* Protected against IDOR by verifying user access to the chat.
*/
export async function getChatMessages(chatId: string): Promise<DrizzleMessage[]> {
if (!chatId) {
console.warn('getChatMessages called without chatId');
return [];
}

const userId = await getCurrentUserIdOnServer();
if (!userId) {
console.warn('getChatMessages: User not authenticated');
return [];
}

try {
// Verify access (ownership or public visibility)
const chat = await dbGetChat(chatId, userId);
if (!chat) {
console.warn(`getChatMessages: User ${userId} does not have access to chat ${chatId}`);
return [];
}
return dbGetMessagesByChatId(chatId);
} catch (error) {
Comment on lines 67 to 87

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

getChatMessages performs an authorization query (dbGetChat) and then fetches messages (dbGetMessagesByChatId) in a separate call. If dbGetMessagesByChatId does not also enforce ownership/visibility, this becomes a TOCTOU-style gap where authorization and data retrieval are decoupled (and it’s also an extra round-trip). Since the stated goal is to prevent IDOR, prefer retrieving messages via a single query that joins/filters by chatId and userId (or a visibility rule) at the DB layer.

Suggestion

Refactor to a single DB method that enforces authorization while fetching messages, e.g. dbGetMessagesForUserChat(chatId, userId) or a join-based query. This reduces the chance of future regressions where one layer forgets the check.

Example shape:

// chat-db.ts
export async function dbGetMessagesForUserChat(chatId: string, userId: string) {
  // SELECT m.* FROM messages m
  // JOIN chats c ON c.id = m.chatId
  // WHERE m.chatId = ? AND c.userId = ?
}

// action
return dbGetMessagesForUserChat(chatId, userId);

Reply with "@CharlieHelps yes please" if you'd like me to add a commit with this suggestion.

console.error(`Error fetching messages for chat ${chatId} in getChatMessages:`, error);
return [];
}
}

/**
* Clears all chats for the authenticated user.
* Protected against IDOR by using session-based userId.
*/
export async function clearChats(
userId?: string | null
userId?: string | null // Kept for backward compatibility but ignored in favor of session userId
): Promise<{ error?: string } | void> {
const currentUserId = userId || (await getCurrentUserIdOnServer())
const currentUserId = await getCurrentUserIdOnServer();
if (!currentUserId) {
console.error('clearChats: No user ID provided or found.')
return { error: 'User ID is required to clear chats' }
console.error('clearChats: User not authenticated.');
return { error: 'Unauthorized: Not authenticated' }
}

try {
Expand All @@ -87,16 +116,20 @@ export async function clearChats(
}
}

/**
* Saves a chat for the authenticated user.
* Protected against IDOR by using session-based userId.
*/
export async function saveChat(chat: OldChatType, userId: string): Promise<string | null> {
if (!userId && !chat.userId) {
console.error('saveChat: userId is required either as a parameter or in chat object.')
const currentUserId = await getCurrentUserIdOnServer();
if (!currentUserId) {
console.error('saveChat: User not authenticated');
return null;
}
const effectiveUserId = userId || chat.userId;

const newChatData: DbNewChat = {
id: chat.id,
userId: effectiveUserId,
userId: currentUserId,
title: chat.title || 'Untitled Chat',
createdAt: chat.createdAt ? new Date(chat.createdAt) : new Date(),
visibility: 'private',
Expand All @@ -105,7 +138,7 @@ export async function saveChat(chat: OldChatType, userId: string): Promise<strin

const newMessagesData: Omit<DbNewMessage, 'chatId'>[] = chat.messages.map(msg => ({
id: msg.id,
userId: effectiveUserId,
userId: currentUserId,
role: msg.role,
content: typeof msg.content === 'object' ? JSON.stringify(msg.content) : msg.content,
createdAt: msg.createdAt ? new Date(msg.createdAt) : new Date(),
Expand All @@ -127,6 +160,13 @@ export async function updateDrawingContext(chatId: string, contextData: { drawnF
return { error: 'User not authenticated' };
}

// Verify chat ownership before adding drawing context
const chat = await dbGetChat(chatId, userId);
if (!chat || chat.userId !== userId) {
console.error(`updateDrawingContext: User ${userId} does not own chat ${chatId}`);
return { error: 'Unauthorized: You do not have permission to update this chat' };
}

const newDrawingMessage: DbNewMessage = {
userId: userId,
chatId: chatId,
Expand All @@ -147,17 +187,22 @@ export async function updateDrawingContext(chatId: string, contextData: { drawnF
}
}

/**
* Saves the system prompt for the authenticated user.
* Uses session-based userId to prevent IDOR.
*/
export async function saveSystemPrompt(
userId: string,
userId: string, // Kept for backward compatibility but ignored in favor of session userId
prompt: string
): Promise<{ success?: boolean; error?: string }> {
if (!userId) return { error: 'User ID is required' }
const currentUserId = await getCurrentUserIdOnServer();
if (!currentUserId) return { error: 'Unauthorized: Not authenticated' }
if (!prompt) return { error: 'Prompt is required' }

try {
await db.update(users)
.set({ systemPrompt: prompt })
.where(eq(users.id, userId));
.where(eq(users.id, currentUserId));

return { success: true }
} catch (error) {
Expand All @@ -166,15 +211,20 @@ export async function saveSystemPrompt(
}
}

/**
* Retrieves the system prompt for the authenticated user.
* Uses session-based userId to prevent IDOR.
*/
export async function getSystemPrompt(
userId: string
userId: string // Kept for backward compatibility but ignored in favor of session userId
): Promise<string | null> {
if (!userId) return null
const currentUserId = await getCurrentUserIdOnServer();
if (!currentUserId) return null

try {
const result = await db.select({ systemPrompt: users.systemPrompt })
.from(users)
.where(eq(users.id, userId))
.where(eq(users.id, currentUserId))
.limit(1);

return result[0]?.systemPrompt || null;
Expand Down
9 changes: 8 additions & 1 deletion lib/actions/users.ts
Original file line number Diff line number Diff line change
Expand Up @@ -184,23 +184,30 @@ export async function saveSelectedModel(model: string): Promise<{ success: boole
/**
* Searches users by email.
* Restricted to authenticated users.
* Sanitized to prevent wildcard enumeration.
*/
export async function searchUsers(query: string) {
noStore();
if (!query) return [];

// Enforce input length limit to mitigate DoS/resource exhaustion
const sanitizedQuery = query.slice(0, 255);

const userId = await getCurrentUserIdOnServer();
if (!userId) {
throw new Error('Unauthorized');
}

try {
// Escape special characters (%, _, \) to prevent wildcard enumeration
const escapedQuery = sanitizedQuery.replace(/[%_\\]/g, '\\$&');

const result = await db.select({
id: users.id,
email: users.email,
})
.from(users)
.where(ilike(users.email, `%${query}%`))
.where(ilike(users.email, `%${escapedQuery}%`))
.limit(10);
Comment on lines 188 to 211

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Escaping %, _, and \ is a good start, but ILIKE wildcard escaping is only correct if the generated SQL includes an explicit ESCAPE '\\' (or if your DB/ORM guarantees backslash escaping semantics). Without ESCAPE, some databases/configurations treat \ as a normal character, and the wildcard may still be interpreted, weakening the intended protection.

Given this is explicitly fixing a high-severity enumeration issue, make the escape semantics unambiguous at the query level.

Suggestion

Ensure the query uses an explicit escape clause (or use an ORM helper that generates one). If Drizzle doesn’t support ESCAPE via ilike directly, consider using sql to express it.

Example (Postgres):

import { sql } from 'drizzle-orm';

const escapedQuery = sanitizedQuery.replace(/[\\%_]/g, '\\$&');
const pattern = `%${escapedQuery}%`;

.where(sql`${users.email} ILIKE ${pattern} ESCAPE '\\'`)

Reply with "@CharlieHelps yes please" if you'd like me to add a commit implementing this.


return result;
Expand Down