diff --git a/apps/sim/app/api/tools/drive/files/route.ts b/apps/sim/app/api/tools/drive/files/route.ts index 33ecb6f10..fa4bb1596 100644 --- a/apps/sim/app/api/tools/drive/files/route.ts +++ b/apps/sim/app/api/tools/drive/files/route.ts @@ -1,6 +1,7 @@ import { type NextRequest, NextResponse } from 'next/server' import { getSession } from '@/lib/auth' import { authorizeCredentialUse } from '@/lib/auth/credential-access' +import { validateAlphanumericId } from '@/lib/core/security/input-validation' import { generateRequestId } from '@/lib/core/utils/request' import { createLogger } from '@/lib/logs/console/logger' import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils' @@ -108,6 +109,14 @@ export async function GET(request: NextRequest) { return NextResponse.json({ error: 'Failed to obtain valid access token' }, { status: 401 }) } + if (folderId) { + const folderIdValidation = validateAlphanumericId(folderId, 'folderId', 50) + if (!folderIdValidation.isValid) { + logger.warn(`[${requestId}] Invalid folderId`, { error: folderIdValidation.error }) + return NextResponse.json({ error: folderIdValidation.error }, { status: 400 }) + } + } + const qParts: string[] = ['trashed = false'] if (folderId) { qParts.push(`'${escapeForDriveQuery(folderId)}' in parents`) diff --git a/apps/sim/app/api/tools/gmail/add-label/route.ts b/apps/sim/app/api/tools/gmail/add-label/route.ts index d4ddcdefb..a8f139180 100644 --- a/apps/sim/app/api/tools/gmail/add-label/route.ts +++ b/apps/sim/app/api/tools/gmail/add-label/route.ts @@ -1,6 +1,7 @@ import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' import { checkHybridAuth } from '@/lib/auth/hybrid' +import { validateAlphanumericId } from '@/lib/core/security/input-validation' import { generateRequestId } from '@/lib/core/utils/request' import { createLogger } from '@/lib/logs/console/logger' @@ -50,6 +51,29 @@ export async function POST(request: NextRequest) { .map((id) => id.trim()) .filter((id) => id.length > 0) + for (const labelId of labelIds) { + const labelIdValidation = validateAlphanumericId(labelId, 'labelId', 255) + if (!labelIdValidation.isValid) { + logger.warn(`[${requestId}] Invalid label ID: ${labelIdValidation.error}`) + return NextResponse.json( + { + success: false, + error: labelIdValidation.error, + }, + { status: 400 } + ) + } + } + + const messageIdValidation = validateAlphanumericId(validatedData.messageId, 'messageId', 255) + if (!messageIdValidation.isValid) { + logger.warn(`[${requestId}] Invalid message ID: ${messageIdValidation.error}`) + return NextResponse.json( + { success: false, error: messageIdValidation.error }, + { status: 400 } + ) + } + const gmailResponse = await fetch( `${GMAIL_API_BASE}/messages/${validatedData.messageId}/modify`, { diff --git a/apps/sim/app/api/tools/gmail/labels/route.ts b/apps/sim/app/api/tools/gmail/labels/route.ts index e4c8aab4b..945db0afa 100644 --- a/apps/sim/app/api/tools/gmail/labels/route.ts +++ b/apps/sim/app/api/tools/gmail/labels/route.ts @@ -3,6 +3,7 @@ import { account } from '@sim/db/schema' import { and, eq } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { getSession } from '@/lib/auth' +import { validateAlphanumericId } from '@/lib/core/security/input-validation' import { generateRequestId } from '@/lib/core/utils/request' import { createLogger } from '@/lib/logs/console/logger' import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils' @@ -38,6 +39,12 @@ export async function GET(request: NextRequest) { return NextResponse.json({ error: 'Credential ID is required' }, { status: 400 }) } + const credentialIdValidation = validateAlphanumericId(credentialId, 'credentialId', 255) + if (!credentialIdValidation.isValid) { + logger.warn(`[${requestId}] Invalid credential ID: ${credentialIdValidation.error}`) + return NextResponse.json({ error: credentialIdValidation.error }, { status: 400 }) + } + let credentials = await db .select() .from(account) diff --git a/apps/sim/app/api/tools/gmail/remove-label/route.ts b/apps/sim/app/api/tools/gmail/remove-label/route.ts index 928d44c36..74e179c91 100644 --- a/apps/sim/app/api/tools/gmail/remove-label/route.ts +++ b/apps/sim/app/api/tools/gmail/remove-label/route.ts @@ -1,6 +1,7 @@ import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' import { checkHybridAuth } from '@/lib/auth/hybrid' +import { validateAlphanumericId } from '@/lib/core/security/input-validation' import { generateRequestId } from '@/lib/core/utils/request' import { createLogger } from '@/lib/logs/console/logger' @@ -53,6 +54,29 @@ export async function POST(request: NextRequest) { .map((id) => id.trim()) .filter((id) => id.length > 0) + for (const labelId of labelIds) { + const labelIdValidation = validateAlphanumericId(labelId, 'labelId', 255) + if (!labelIdValidation.isValid) { + logger.warn(`[${requestId}] Invalid label ID: ${labelIdValidation.error}`) + return NextResponse.json( + { + success: false, + error: labelIdValidation.error, + }, + { status: 400 } + ) + } + } + + const messageIdValidation = validateAlphanumericId(validatedData.messageId, 'messageId', 255) + if (!messageIdValidation.isValid) { + logger.warn(`[${requestId}] Invalid message ID: ${messageIdValidation.error}`) + return NextResponse.json( + { success: false, error: messageIdValidation.error }, + { status: 400 } + ) + } + const gmailResponse = await fetch( `${GMAIL_API_BASE}/messages/${validatedData.messageId}/modify`, { diff --git a/apps/sim/app/api/tools/google_calendar/calendars/route.ts b/apps/sim/app/api/tools/google_calendar/calendars/route.ts index 2e92521db..7fc17db6e 100644 --- a/apps/sim/app/api/tools/google_calendar/calendars/route.ts +++ b/apps/sim/app/api/tools/google_calendar/calendars/route.ts @@ -1,5 +1,6 @@ import { type NextRequest, NextResponse } from 'next/server' import { authorizeCredentialUse } from '@/lib/auth/credential-access' +import { validateUUID } from '@/lib/core/security/input-validation' import { generateRequestId } from '@/lib/core/utils/request' import { createLogger } from '@/lib/logs/console/logger' import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils' @@ -25,7 +26,6 @@ export async function GET(request: NextRequest) { logger.info(`[${requestId}] Google Calendar calendars request received`) try { - // Get the credential ID from the query params const { searchParams } = new URL(request.url) const credentialId = searchParams.get('credentialId') const workflowId = searchParams.get('workflowId') || undefined @@ -34,12 +34,25 @@ export async function GET(request: NextRequest) { logger.warn(`[${requestId}] Missing credentialId parameter`) return NextResponse.json({ error: 'Credential ID is required' }, { status: 400 }) } + + const credentialValidation = validateUUID(credentialId, 'credentialId') + if (!credentialValidation.isValid) { + logger.warn(`[${requestId}] Invalid credentialId format`, { credentialId }) + return NextResponse.json({ error: credentialValidation.error }, { status: 400 }) + } + + if (workflowId) { + const workflowValidation = validateUUID(workflowId, 'workflowId') + if (!workflowValidation.isValid) { + logger.warn(`[${requestId}] Invalid workflowId format`, { workflowId }) + return NextResponse.json({ error: workflowValidation.error }, { status: 400 }) + } + } const authz = await authorizeCredentialUse(request, { credentialId, workflowId }) if (!authz.ok || !authz.credentialOwnerUserId) { return NextResponse.json({ error: authz.error || 'Unauthorized' }, { status: 403 }) } - // Refresh access token if needed using the utility function const accessToken = await refreshAccessTokenIfNeeded( credentialId, authz.credentialOwnerUserId, @@ -50,7 +63,6 @@ export async function GET(request: NextRequest) { return NextResponse.json({ error: 'Failed to obtain valid access token' }, { status: 401 }) } - // Fetch calendars from Google Calendar API logger.info(`[${requestId}] Fetching calendars from Google Calendar API`) const calendarResponse = await fetch( 'https://www.googleapis.com/calendar/v3/users/me/calendarList', @@ -81,7 +93,6 @@ export async function GET(request: NextRequest) { const data = await calendarResponse.json() const calendars: CalendarListItem[] = data.items || [] - // Sort calendars with primary first, then alphabetically calendars.sort((a, b) => { if (a.primary && !b.primary) return -1 if (!a.primary && b.primary) return 1 diff --git a/apps/sim/app/api/tools/microsoft-teams/channels/route.ts b/apps/sim/app/api/tools/microsoft-teams/channels/route.ts index 990819bc7..0d07ca433 100644 --- a/apps/sim/app/api/tools/microsoft-teams/channels/route.ts +++ b/apps/sim/app/api/tools/microsoft-teams/channels/route.ts @@ -1,5 +1,6 @@ import { NextResponse } from 'next/server' import { authorizeCredentialUse } from '@/lib/auth/credential-access' +import { validateMicrosoftGraphId } from '@/lib/core/security/input-validation' import { createLogger } from '@/lib/logs/console/logger' import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils' @@ -23,6 +24,12 @@ export async function POST(request: Request) { return NextResponse.json({ error: 'Team ID is required' }, { status: 400 }) } + const teamIdValidation = validateMicrosoftGraphId(teamId, 'Team ID') + if (!teamIdValidation.isValid) { + logger.warn('Invalid team ID provided', { teamId, error: teamIdValidation.error }) + return NextResponse.json({ error: teamIdValidation.error }, { status: 400 }) + } + try { const authz = await authorizeCredentialUse(request as any, { credentialId: credential, @@ -70,7 +77,6 @@ export async function POST(request: Request) { endpoint: `https://graph.microsoft.com/v1.0/teams/${teamId}/channels`, }) - // Check for auth errors specifically if (response.status === 401) { return NextResponse.json( { @@ -93,7 +99,6 @@ export async function POST(request: Request) { } catch (innerError) { logger.error('Error during API requests:', innerError) - // Check if it's an authentication error const errorMessage = innerError instanceof Error ? innerError.message : String(innerError) if ( errorMessage.includes('auth') || diff --git a/apps/sim/app/api/tools/microsoft-teams/chats/route.ts b/apps/sim/app/api/tools/microsoft-teams/chats/route.ts index 439db3454..356f92475 100644 --- a/apps/sim/app/api/tools/microsoft-teams/chats/route.ts +++ b/apps/sim/app/api/tools/microsoft-teams/chats/route.ts @@ -1,5 +1,6 @@ import { NextResponse } from 'next/server' import { authorizeCredentialUse } from '@/lib/auth/credential-access' +import { validateMicrosoftGraphId } from '@/lib/core/security/input-validation' import { createLogger } from '@/lib/logs/console/logger' import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils' @@ -7,21 +8,35 @@ export const dynamic = 'force-dynamic' const logger = createLogger('TeamsChatsAPI') -// Helper function to get chat members and create a meaningful name +/** + * Helper function to get chat members and create a meaningful name + * + * @param chatId - Microsoft Teams chat ID to get display name for + * @param accessToken - Access token for Microsoft Graph API + * @param chatTopic - Optional existing chat topic + * @returns A meaningful display name for the chat + */ const getChatDisplayName = async ( chatId: string, accessToken: string, chatTopic?: string ): Promise => { try { - // If the chat already has a topic, use it + const chatIdValidation = validateMicrosoftGraphId(chatId, 'chatId') + if (!chatIdValidation.isValid) { + logger.warn('Invalid chat ID in getChatDisplayName', { + error: chatIdValidation.error, + chatId: chatId.substring(0, 50), + }) + return `Chat ${chatId.substring(0, 8)}...` + } + if (chatTopic?.trim() && chatTopic !== 'null') { return chatTopic } - // Fetch chat members to create a meaningful name const membersResponse = await fetch( - `https://graph.microsoft.com/v1.0/chats/${chatId}/members`, + `https://graph.microsoft.com/v1.0/chats/${encodeURIComponent(chatId)}/members`, { method: 'GET', headers: { @@ -35,27 +50,25 @@ const getChatDisplayName = async ( const membersData = await membersResponse.json() const members = membersData.value || [] - // Filter out the current user and get display names const memberNames = members .filter((member: any) => member.displayName && member.displayName !== 'Unknown') .map((member: any) => member.displayName) - .slice(0, 3) // Limit to first 3 names to avoid very long names + .slice(0, 3) if (memberNames.length > 0) { if (memberNames.length === 1) { - return memberNames[0] // 1:1 chat + return memberNames[0] } if (memberNames.length === 2) { - return memberNames.join(' & ') // 2-person group + return memberNames.join(' & ') } - return `${memberNames.slice(0, 2).join(', ')} & ${memberNames.length - 2} more` // Larger group + return `${memberNames.slice(0, 2).join(', ')} & ${memberNames.length - 2} more` } } - // Fallback: try to get a better name from recent messages try { const messagesResponse = await fetch( - `https://graph.microsoft.com/v1.0/chats/${chatId}/messages?$top=10&$orderby=createdDateTime desc`, + `https://graph.microsoft.com/v1.0/chats/${encodeURIComponent(chatId)}/messages?$top=10&$orderby=createdDateTime desc`, { method: 'GET', headers: { @@ -69,14 +82,12 @@ const getChatDisplayName = async ( const messagesData = await messagesResponse.json() const messages = messagesData.value || [] - // Look for chat rename events for (const message of messages) { if (message.eventDetail?.chatDisplayName) { return message.eventDetail.chatDisplayName } } - // Get unique sender names from recent messages as last resort const senderNames = [ ...new Set( messages @@ -103,7 +114,6 @@ const getChatDisplayName = async ( ) } - // Final fallback return `Chat ${chatId.split(':')[0] || chatId.substring(0, 8)}...` } catch (error) { logger.warn( @@ -146,7 +156,6 @@ export async function POST(request: Request) { return NextResponse.json({ error: 'Could not retrieve access token' }, { status: 401 }) } - // Now try to fetch the chats const response = await fetch('https://graph.microsoft.com/v1.0/me/chats', { method: 'GET', headers: { @@ -163,7 +172,6 @@ export async function POST(request: Request) { endpoint: 'https://graph.microsoft.com/v1.0/me/chats', }) - // Check for auth errors specifically if (response.status === 401) { return NextResponse.json( { @@ -179,7 +187,6 @@ export async function POST(request: Request) { const data = await response.json() - // Process chats with enhanced display names const chats = await Promise.all( data.value.map(async (chat: any) => ({ id: chat.id, @@ -193,7 +200,6 @@ export async function POST(request: Request) { } catch (innerError) { logger.error('Error during API requests:', innerError) - // Check if it's an authentication error const errorMessage = innerError instanceof Error ? innerError.message : String(innerError) if ( errorMessage.includes('auth') || diff --git a/apps/sim/app/api/tools/onedrive/files/route.ts b/apps/sim/app/api/tools/onedrive/files/route.ts index 0d916410f..0a551f5bd 100644 --- a/apps/sim/app/api/tools/onedrive/files/route.ts +++ b/apps/sim/app/api/tools/onedrive/files/route.ts @@ -4,6 +4,7 @@ import { account } from '@sim/db/schema' import { eq } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { getSession } from '@/lib/auth' +import { validateMicrosoftGraphId } from '@/lib/core/security/input-validation' import { createLogger } from '@/lib/logs/console/logger' import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils' @@ -36,6 +37,12 @@ export async function GET(request: NextRequest) { return NextResponse.json({ error: 'Credential ID is required' }, { status: 400 }) } + const credentialIdValidation = validateMicrosoftGraphId(credentialId, 'credentialId') + if (!credentialIdValidation.isValid) { + logger.warn(`[${requestId}] Invalid credential ID`, { error: credentialIdValidation.error }) + return NextResponse.json({ error: credentialIdValidation.error }, { status: 400 }) + } + logger.info(`[${requestId}] Fetching credential`, { credentialId }) const credentials = await db.select().from(account).where(eq(account.id, credentialId)).limit(1) diff --git a/apps/sim/app/api/tools/onedrive/folders/route.ts b/apps/sim/app/api/tools/onedrive/folders/route.ts index b62d41179..61adffde4 100644 --- a/apps/sim/app/api/tools/onedrive/folders/route.ts +++ b/apps/sim/app/api/tools/onedrive/folders/route.ts @@ -4,6 +4,7 @@ import { account } from '@sim/db/schema' import { eq } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { getSession } from '@/lib/auth' +import { validateMicrosoftGraphId } from '@/lib/core/security/input-validation' import { createLogger } from '@/lib/logs/console/logger' import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils' @@ -33,6 +34,12 @@ export async function GET(request: NextRequest) { return NextResponse.json({ error: 'Credential ID is required' }, { status: 400 }) } + const credentialIdValidation = validateMicrosoftGraphId(credentialId, 'credentialId') + if (!credentialIdValidation.isValid) { + logger.warn(`[${requestId}] Invalid credential ID`, { error: credentialIdValidation.error }) + return NextResponse.json({ error: credentialIdValidation.error }, { status: 400 }) + } + const credentials = await db.select().from(account).where(eq(account.id, credentialId)).limit(1) if (!credentials.length) { return NextResponse.json({ error: 'Credential not found' }, { status: 404 }) @@ -48,7 +55,6 @@ export async function GET(request: NextRequest) { return NextResponse.json({ error: 'Failed to obtain valid access token' }, { status: 401 }) } - // Build URL for OneDrive folders let url = `https://graph.microsoft.com/v1.0/me/drive/root/children?$filter=folder ne null&$select=id,name,folder,webUrl,createdDateTime,lastModifiedDateTime&$top=50` if (query) { @@ -71,7 +77,7 @@ export async function GET(request: NextRequest) { const data = await response.json() const folders = (data.value || []) - .filter((item: MicrosoftGraphDriveItem) => item.folder) // Only folders + .filter((item: MicrosoftGraphDriveItem) => item.folder) .map((folder: MicrosoftGraphDriveItem) => ({ id: folder.id, name: folder.name, diff --git a/apps/sim/app/api/tools/onedrive/upload/route.ts b/apps/sim/app/api/tools/onedrive/upload/route.ts index db7936120..c5c4d29ae 100644 --- a/apps/sim/app/api/tools/onedrive/upload/route.ts +++ b/apps/sim/app/api/tools/onedrive/upload/route.ts @@ -2,6 +2,7 @@ import { type NextRequest, NextResponse } from 'next/server' import * as XLSX from 'xlsx' import { z } from 'zod' import { checkHybridAuth } from '@/lib/auth/hybrid' +import { validateMicrosoftGraphId } from '@/lib/core/security/input-validation' import { generateRequestId } from '@/lib/core/utils/request' import { createLogger } from '@/lib/logs/console/logger' import { @@ -28,9 +29,9 @@ const ExcelValuesSchema = z.union([ const OneDriveUploadSchema = z.object({ accessToken: z.string().min(1, 'Access token is required'), fileName: z.string().min(1, 'File name is required'), - file: z.any().optional(), // UserFile object (optional for blank Excel creation) + file: z.any().optional(), folderId: z.string().optional().nullable(), - mimeType: z.string().nullish(), // Accept string, null, or undefined + mimeType: z.string().nullish(), values: ExcelValuesSchema.optional().nullable(), }) @@ -62,24 +63,19 @@ export async function POST(request: NextRequest) { let fileBuffer: Buffer let mimeType: string - // Check if we're creating a blank Excel file const isExcelCreation = validatedData.mimeType === 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' && !validatedData.file if (isExcelCreation) { - // Create a blank Excel workbook - const workbook = XLSX.utils.book_new() const worksheet = XLSX.utils.aoa_to_sheet([[]]) XLSX.utils.book_append_sheet(workbook, worksheet, 'Sheet1') - // Generate XLSX file as buffer const xlsxBuffer = XLSX.write(workbook, { type: 'buffer', bookType: 'xlsx' }) fileBuffer = Buffer.from(xlsxBuffer) mimeType = 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' } else { - // Handle regular file upload const rawFile = validatedData.file if (!rawFile) { @@ -108,7 +104,6 @@ export async function POST(request: NextRequest) { fileToProcess = rawFile } - // Convert to UserFile format let userFile try { userFile = processSingleFileToUserFile(fileToProcess, requestId, logger) @@ -138,7 +133,7 @@ export async function POST(request: NextRequest) { mimeType = userFile.type || 'application/octet-stream' } - const maxSize = 250 * 1024 * 1024 // 250MB + const maxSize = 250 * 1024 * 1024 if (fileBuffer.length > maxSize) { const sizeMB = (fileBuffer.length / (1024 * 1024)).toFixed(2) logger.warn(`[${requestId}] File too large: ${sizeMB}MB`) @@ -151,7 +146,6 @@ export async function POST(request: NextRequest) { ) } - // Ensure file name has an appropriate extension let fileName = validatedData.fileName const hasExtension = fileName.includes('.') && fileName.lastIndexOf('.') > 0 @@ -169,6 +163,17 @@ export async function POST(request: NextRequest) { const folderId = validatedData.folderId?.trim() if (folderId && folderId !== '') { + const folderIdValidation = validateMicrosoftGraphId(folderId, 'folderId') + if (!folderIdValidation.isValid) { + logger.warn(`[${requestId}] Invalid folder ID`, { error: folderIdValidation.error }) + return NextResponse.json( + { + success: false, + error: folderIdValidation.error, + }, + { status: 400 } + ) + } uploadUrl = `${MICROSOFT_GRAPH_BASE}/me/drive/items/${encodeURIComponent(folderId)}:/${encodeURIComponent(fileName)}:/content` } else { uploadUrl = `${MICROSOFT_GRAPH_BASE}/me/drive/root:/${encodeURIComponent(fileName)}:/content` @@ -197,14 +202,12 @@ export async function POST(request: NextRequest) { const fileData = await uploadResponse.json() - // If this is an Excel creation and values were provided, write them using the Excel API let excelWriteResult: any | undefined const shouldWriteExcelContent = isExcelCreation && Array.isArray(excelValues) && excelValues.length > 0 if (shouldWriteExcelContent) { try { - // Create a workbook session to ensure reliability and persistence of changes let workbookSessionId: string | undefined const sessionResp = await fetch( `${MICROSOFT_GRAPH_BASE}/me/drive/items/${encodeURIComponent(fileData.id)}/workbook/createSession`, @@ -223,7 +226,6 @@ export async function POST(request: NextRequest) { workbookSessionId = sessionData?.id } - // Determine the first worksheet name let sheetName = 'Sheet1' try { const listUrl = `${MICROSOFT_GRAPH_BASE}/me/drive/items/${encodeURIComponent( @@ -272,7 +274,6 @@ export async function POST(request: NextRequest) { return paddedRow }) - // Compute concise end range from A1 and matrix size (no network round-trip) const indexToColLetters = (index: number): string => { let n = index let s = '' @@ -313,7 +314,6 @@ export async function POST(request: NextRequest) { statusText: excelWriteResponse?.statusText, error: errorText, }) - // Do not fail the entire request; return upload success with write error details excelWriteResult = { success: false, error: `Excel write failed: ${excelWriteResponse?.statusText || 'unknown'}`, @@ -321,7 +321,6 @@ export async function POST(request: NextRequest) { } } else { const writeData = await excelWriteResponse.json() - // The Range PATCH returns a Range object; log address and values length const addr = writeData.address || writeData.addressLocal const v = writeData.values || [] excelWriteResult = { @@ -333,7 +332,6 @@ export async function POST(request: NextRequest) { } } - // Attempt to close the workbook session if one was created if (workbookSessionId) { try { const closeResp = await fetch( diff --git a/apps/sim/app/api/tools/outlook/folders/route.ts b/apps/sim/app/api/tools/outlook/folders/route.ts index 90e4de55e..91395e684 100644 --- a/apps/sim/app/api/tools/outlook/folders/route.ts +++ b/apps/sim/app/api/tools/outlook/folders/route.ts @@ -3,6 +3,7 @@ import { account } from '@sim/db/schema' import { eq } from 'drizzle-orm' import { NextResponse } from 'next/server' import { getSession } from '@/lib/auth' +import { validateAlphanumericId } from '@/lib/core/security/input-validation' import { generateRequestId } from '@/lib/core/utils/request' import { createLogger } from '@/lib/logs/console/logger' import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils' @@ -29,8 +30,13 @@ export async function GET(request: Request) { return NextResponse.json({ error: 'Credential ID is required' }, { status: 400 }) } + const credentialIdValidation = validateAlphanumericId(credentialId, 'credentialId') + if (!credentialIdValidation.isValid) { + logger.warn('Invalid credentialId format', { error: credentialIdValidation.error }) + return NextResponse.json({ error: credentialIdValidation.error }, { status: 400 }) + } + try { - // Ensure we have a session for permission checks const sessionUserId = session?.user?.id || '' if (!sessionUserId) { @@ -38,7 +44,6 @@ export async function GET(request: Request) { return NextResponse.json({ error: 'Authentication required' }, { status: 401 }) } - // Resolve the credential owner to support collaborator-owned credentials const creds = await db.select().from(account).where(eq(account.id, credentialId)).limit(1) if (!creds.length) { logger.warn('Credential not found', { credentialId }) @@ -79,7 +84,6 @@ export async function GET(request: Request) { endpoint: 'https://graph.microsoft.com/v1.0/me/mailFolders', }) - // Check for auth errors specifically if (response.status === 401) { return NextResponse.json( { @@ -96,7 +100,6 @@ export async function GET(request: Request) { const data = await response.json() const folders = data.value || [] - // Transform folders to match the expected format const transformedFolders = folders.map((folder: OutlookFolder) => ({ id: folder.id, name: folder.displayName, @@ -111,7 +114,6 @@ export async function GET(request: Request) { } catch (innerError) { logger.error('Error during API requests:', innerError) - // Check if it's an authentication error const errorMessage = innerError instanceof Error ? innerError.message : String(innerError) if ( errorMessage.includes('auth') || diff --git a/apps/sim/app/api/tools/sharepoint/sites/route.ts b/apps/sim/app/api/tools/sharepoint/sites/route.ts index 3448ff22e..2f39cc049 100644 --- a/apps/sim/app/api/tools/sharepoint/sites/route.ts +++ b/apps/sim/app/api/tools/sharepoint/sites/route.ts @@ -4,6 +4,7 @@ import { account } from '@sim/db/schema' import { eq } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { getSession } from '@/lib/auth' +import { validateAlphanumericId } from '@/lib/core/security/input-validation' import { createLogger } from '@/lib/logs/console/logger' import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils' import type { SharepointSite } from '@/tools/sharepoint/types' @@ -32,6 +33,12 @@ export async function GET(request: NextRequest) { return NextResponse.json({ error: 'Credential ID is required' }, { status: 400 }) } + const credentialIdValidation = validateAlphanumericId(credentialId, 'credentialId', 255) + if (!credentialIdValidation.isValid) { + logger.warn(`[${requestId}] Invalid credential ID`, { error: credentialIdValidation.error }) + return NextResponse.json({ error: credentialIdValidation.error }, { status: 400 }) + } + const credentials = await db.select().from(account).where(eq(account.id, credentialId)).limit(1) if (!credentials.length) { return NextResponse.json({ error: 'Credential not found' }, { status: 404 }) @@ -47,8 +54,6 @@ export async function GET(request: NextRequest) { return NextResponse.json({ error: 'Failed to obtain valid access token' }, { status: 401 }) } - // Build URL for SharePoint sites - // Use search=* to get all sites the user has access to, or search for specific query const searchQuery = query || '*' const url = `https://graph.microsoft.com/v1.0/sites?search=${encodeURIComponent(searchQuery)}&$select=id,name,displayName,webUrl,createdDateTime,lastModifiedDateTime&$top=50` diff --git a/apps/sim/app/api/tools/slack/channels/route.ts b/apps/sim/app/api/tools/slack/channels/route.ts index be572492b..d48d06613 100644 --- a/apps/sim/app/api/tools/slack/channels/route.ts +++ b/apps/sim/app/api/tools/slack/channels/route.ts @@ -1,5 +1,6 @@ import { NextResponse } from 'next/server' import { authorizeCredentialUse } from '@/lib/auth/credential-access' +import { validateAlphanumericId } from '@/lib/core/security/input-validation' import { generateRequestId } from '@/lib/core/utils/request' import { createLogger } from '@/lib/logs/console/logger' import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils' @@ -93,7 +94,6 @@ export async function POST(request: Request) { } } - // Filter to channels the bot can access and format the response const channels = (data.channels || []) .filter((channel: SlackChannel) => { const canAccess = !channel.is_archived && (channel.is_member || !channel.is_private) @@ -106,6 +106,28 @@ export async function POST(request: Request) { return canAccess }) + .filter((channel: SlackChannel) => { + const validation = validateAlphanumericId(channel.id, 'channelId', 50) + + if (!validation.isValid) { + logger.warn('Invalid channel ID received from Slack API', { + channelId: channel.id, + channelName: channel.name, + error: validation.error, + }) + return false + } + + if (!/^[CDG][A-Z0-9]+$/i.test(channel.id)) { + logger.warn('Channel ID does not match Slack format', { + channelId: channel.id, + channelName: channel.name, + }) + return false + } + + return true + }) .map((channel: SlackChannel) => ({ id: channel.id, name: channel.name, diff --git a/apps/sim/app/api/tools/slack/users/route.ts b/apps/sim/app/api/tools/slack/users/route.ts index 97d73c88d..666800f56 100644 --- a/apps/sim/app/api/tools/slack/users/route.ts +++ b/apps/sim/app/api/tools/slack/users/route.ts @@ -1,5 +1,6 @@ import { NextResponse } from 'next/server' import { authorizeCredentialUse } from '@/lib/auth/credential-access' +import { validateAlphanumericId } from '@/lib/core/security/input-validation' import { generateRequestId } from '@/lib/core/utils/request' import { createLogger } from '@/lib/logs/console/logger' import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils' @@ -20,13 +21,21 @@ export async function POST(request: Request) { try { const requestId = generateRequestId() const body = await request.json() - const { credential, workflowId } = body + const { credential, workflowId, userId } = body if (!credential) { logger.error('Missing credential in request') return NextResponse.json({ error: 'Credential is required' }, { status: 400 }) } + if (userId !== undefined && userId !== null) { + const validation = validateAlphanumericId(userId, 'userId', 100) + if (!validation.isValid) { + logger.warn('Invalid Slack user ID', { userId, error: validation.error }) + return NextResponse.json({ error: validation.error }, { status: 400 }) + } + } + let accessToken: string const isBotToken = credential.startsWith('xoxb-') @@ -63,6 +72,17 @@ export async function POST(request: Request) { logger.info('Using OAuth token for Slack API') } + if (userId) { + const userData = await fetchSlackUser(accessToken, userId) + const user = { + id: userData.user.id, + name: userData.user.name, + real_name: userData.user.real_name || userData.user.name, + } + logger.info(`Successfully fetched Slack user: ${userId}`) + return NextResponse.json({ user }) + } + const data = await fetchSlackUsers(accessToken) const users = (data.members || []) @@ -87,6 +107,31 @@ export async function POST(request: Request) { } } +async function fetchSlackUser(accessToken: string, userId: string) { + const url = new URL('https://slack.com/api/users.info') + url.searchParams.append('user', userId) + + const response = await fetch(url.toString(), { + method: 'GET', + headers: { + Authorization: `Bearer ${accessToken}`, + 'Content-Type': 'application/json', + }, + }) + + if (!response.ok) { + throw new Error(`Slack API error: ${response.status} ${response.statusText}`) + } + + const data = await response.json() + + if (!data.ok) { + throw new Error(data.error || 'Failed to fetch user') + } + + return data +} + async function fetchSlackUsers(accessToken: string) { const url = new URL('https://slack.com/api/users.list') url.searchParams.append('limit', '200') diff --git a/apps/sim/app/api/tools/wealthbox/items/route.ts b/apps/sim/app/api/tools/wealthbox/items/route.ts index b8b7a514d..dd041f5d9 100644 --- a/apps/sim/app/api/tools/wealthbox/items/route.ts +++ b/apps/sim/app/api/tools/wealthbox/items/route.ts @@ -3,6 +3,7 @@ import { account } from '@sim/db/schema' import { eq } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { getSession } from '@/lib/auth' +import { validateEnum, validatePathSegment } from '@/lib/core/security/input-validation' import { generateRequestId } from '@/lib/core/utils/request' import { createLogger } from '@/lib/logs/console/logger' import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils' @@ -11,7 +12,6 @@ export const dynamic = 'force-dynamic' const logger = createLogger('WealthboxItemsAPI') -// Interface for transformed Wealthbox items interface WealthboxItem { id: string name: string @@ -45,12 +45,23 @@ export async function GET(request: NextRequest) { return NextResponse.json({ error: 'Credential ID is required' }, { status: 400 }) } - if (type !== 'contact') { + const credentialIdValidation = validatePathSegment(credentialId, { + paramName: 'credentialId', + maxLength: 100, + allowHyphens: true, + allowUnderscores: true, + allowDots: false, + }) + if (!credentialIdValidation.isValid) { + logger.warn(`[${requestId}] Invalid credentialId format: ${credentialId}`) + return NextResponse.json({ error: credentialIdValidation.error }, { status: 400 }) + } + + const ALLOWED_TYPES = ['contact'] as const + const typeValidation = validateEnum(type, ALLOWED_TYPES, 'type') + if (!typeValidation.isValid) { logger.warn(`[${requestId}] Invalid item type: ${type}`) - return NextResponse.json( - { error: 'Invalid item type. Only contact is supported.' }, - { status: 400 } - ) + return NextResponse.json({ error: typeValidation.error }, { status: 400 }) } const credentials = await db.select().from(account).where(eq(account.id, credentialId)).limit(1) diff --git a/apps/sim/app/api/tools/webflow/sites/route.ts b/apps/sim/app/api/tools/webflow/sites/route.ts index 2cfc4698a..f5fd93ee2 100644 --- a/apps/sim/app/api/tools/webflow/sites/route.ts +++ b/apps/sim/app/api/tools/webflow/sites/route.ts @@ -1,5 +1,6 @@ import { NextResponse } from 'next/server' import { authorizeCredentialUse } from '@/lib/auth/credential-access' +import { validateAlphanumericId } from '@/lib/core/security/input-validation' import { generateRequestId } from '@/lib/core/utils/request' import { createLogger } from '@/lib/logs/console/logger' import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils' @@ -12,13 +13,21 @@ export async function POST(request: Request) { try { const requestId = generateRequestId() const body = await request.json() - const { credential, workflowId } = body + const { credential, workflowId, siteId } = body if (!credential) { logger.error('Missing credential in request') return NextResponse.json({ error: 'Credential is required' }, { status: 400 }) } + if (siteId) { + const siteIdValidation = validateAlphanumericId(siteId, 'siteId') + if (!siteIdValidation.isValid) { + logger.error('Invalid siteId', { error: siteIdValidation.error }) + return NextResponse.json({ error: siteIdValidation.error }, { status: 400 }) + } + } + const authz = await authorizeCredentialUse(request as any, { credentialId: credential, workflowId, @@ -46,7 +55,11 @@ export async function POST(request: Request) { ) } - const response = await fetch('https://api.webflow.com/v2/sites', { + const url = siteId + ? `https://api.webflow.com/v2/sites/${siteId}` + : 'https://api.webflow.com/v2/sites' + + const response = await fetch(url, { headers: { Authorization: `Bearer ${accessToken}`, accept: 'application/json', @@ -58,6 +71,7 @@ export async function POST(request: Request) { logger.error('Failed to fetch Webflow sites', { status: response.status, error: errorData, + siteId: siteId || 'all', }) return NextResponse.json( { error: 'Failed to fetch Webflow sites', details: errorData }, @@ -66,7 +80,13 @@ export async function POST(request: Request) { } const data = await response.json() - const sites = data.sites || [] + + let sites: any[] + if (siteId) { + sites = [data] + } else { + sites = data.sites || [] + } const formattedSites = sites.map((site: any) => ({ id: site.id, diff --git a/apps/sim/lib/core/security/input-validation.ts b/apps/sim/lib/core/security/input-validation.ts index 0d0bd41a3..cf1970d33 100644 --- a/apps/sim/lib/core/security/input-validation.ts +++ b/apps/sim/lib/core/security/input-validation.ts @@ -958,3 +958,112 @@ export function createPinnedUrl(originalUrl: string, resolvedIP: string): string const port = parsed.port ? `:${parsed.port}` : '' return `${parsed.protocol}//${resolvedIP}${port}${parsed.pathname}${parsed.search}` } + +/** + * Validates a Google Calendar ID + * + * Google Calendar IDs can be: + * - "primary" (literal string for the user's primary calendar) + * - Email addresses (for user calendars) + * - Alphanumeric strings with hyphens, underscores, and dots (for other calendars) + * + * This validator allows these legitimate formats while blocking path traversal and injection attempts. + * + * @param value - The calendar ID to validate + * @param paramName - Name of the parameter for error messages + * @returns ValidationResult + * + * @example + * ```typescript + * const result = validateGoogleCalendarId(calendarId, 'calendarId') + * if (!result.isValid) { + * return NextResponse.json({ error: result.error }, { status: 400 }) + * } + * ``` + */ +export function validateGoogleCalendarId( + value: string | null | undefined, + paramName = 'calendarId' +): ValidationResult { + if (value === null || value === undefined || value === '') { + return { + isValid: false, + error: `${paramName} is required`, + } + } + + if (value === 'primary') { + return { isValid: true, sanitized: value } + } + + const pathTraversalPatterns = [ + '../', + '..\\', + '%2e%2e%2f', + '%2e%2e/', + '..%2f', + '%2e%2e%5c', + '%2e%2e\\', + '..%5c', + '%252e%252e%252f', + ] + + const lowerValue = value.toLowerCase() + for (const pattern of pathTraversalPatterns) { + if (lowerValue.includes(pattern)) { + logger.warn('Path traversal attempt in Google Calendar ID', { + paramName, + value: value.substring(0, 100), + }) + return { + isValid: false, + error: `${paramName} contains invalid path traversal sequence`, + } + } + } + + if (/[\x00-\x1f\x7f]/.test(value) || value.includes('%00')) { + logger.warn('Control characters in Google Calendar ID', { paramName }) + return { + isValid: false, + error: `${paramName} contains invalid control characters`, + } + } + + if (value.includes('\n') || value.includes('\r')) { + return { + isValid: false, + error: `${paramName} contains invalid newline characters`, + } + } + + const emailPattern = /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/ + if (emailPattern.test(value)) { + return { isValid: true, sanitized: value } + } + + const calendarIdPattern = /^[a-zA-Z0-9._@%#+-]+$/ + if (!calendarIdPattern.test(value)) { + logger.warn('Invalid Google Calendar ID format', { + paramName, + value: value.substring(0, 100), + }) + return { + isValid: false, + error: `${paramName} format is invalid. Must be "primary", an email address, or an alphanumeric ID`, + } + } + + if (value.length > 255) { + logger.warn('Google Calendar ID exceeds maximum length', { + paramName, + length: value.length, + }) + return { + isValid: false, + error: `${paramName} exceeds maximum length of 255 characters`, + } + } + + return { isValid: true, sanitized: value } +}