From 1cdb3c677dc9abe2b8270587c5e12e0874b4b627 Mon Sep 17 00:00:00 2001 From: waleed Date: Sun, 10 May 2026 13:45:49 -0700 Subject: [PATCH 1/7] fix(security): close IDOR gaps in OAuth credential and execution authorization Routes that called resolveOAuthAccountId followed by a conditional workspace permission check (only run when workspaceId was set) silently skipped all ownership validation on the legacy account-ID fallback path. Any authenticated user could supply a raw account.id to access another tenant's OAuth credentials. - Replace resolveOAuthAccountId + conditional perm check with authorizeCredentialUse in: auth/oauth/wealthbox/item, tools/gmail/label, tools/onedrive/files, tools/onedrive/folder, tools/outlook/folders, tools/wealthbox/item (routes 1, 3-7) - Add authorizeCredentialUse ownership gate before resolveVertexCredential in providers/route.ts (route 2) - Add verifyFileAccess check on the user-supplied file key before downloadFileFromStorage in tools/wordpress/upload (route 8) - Add workflowId param to PauseResumeManager methods (enqueueOrStartResume, beginPausedCancellation, completePausedCancellation, blockQueuedResumesForCancellation, clearPausedCancellationIntent, getPausedCancellationStatus, processQueuedResumes) and filter all pausedExecutions lookups by workflowId so callers cannot act on another tenant's paused execution by supplying a foreign executionId (route 9) - Update all call sites (cancel, resume, poll routes) to pass workflowId --- .../api/auth/oauth/wealthbox/item/route.ts | 53 +++--------- apps/sim/app/api/providers/route.ts | 16 ++++ .../[executionId]/[contextId]/route.ts | 1 + apps/sim/app/api/resume/poll/route.ts | 1 + apps/sim/app/api/tools/gmail/label/route.ts | 81 ++++--------------- .../sim/app/api/tools/onedrive/files/route.ts | 51 +++--------- .../app/api/tools/onedrive/folder/route.ts | 49 +++-------- .../app/api/tools/outlook/folders/route.ts | 58 ++++--------- .../sim/app/api/tools/wealthbox/item/route.ts | 53 +++--------- .../app/api/tools/wordpress/upload/route.ts | 18 +++++ .../executions/[executionId]/cancel/route.ts | 64 +++++++++------ .../executor/human-in-the-loop-manager.ts | 65 ++++++++++++--- 12 files changed, 205 insertions(+), 305 deletions(-) diff --git a/apps/sim/app/api/auth/oauth/wealthbox/item/route.ts b/apps/sim/app/api/auth/oauth/wealthbox/item/route.ts index 924e5c68fcf..9e43c3bc8a0 100644 --- a/apps/sim/app/api/auth/oauth/wealthbox/item/route.ts +++ b/apps/sim/app/api/auth/oauth/wealthbox/item/route.ts @@ -1,15 +1,12 @@ -import { db } from '@sim/db' -import { account } from '@sim/db/schema' import { createLogger } from '@sim/logger' -import { eq } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { wealthboxOAuthItemContract } from '@/lib/api/contracts/selectors/wealthbox' import { parseRequest } from '@/lib/api/server' -import { getSession } from '@/lib/auth' +import { authorizeCredentialUse } from '@/lib/auth/credential-access' import { validateEnum, validatePathSegment } from '@/lib/core/security/input-validation' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { refreshAccessTokenIfNeeded, resolveOAuthAccountId } from '@/app/api/auth/oauth/utils' +import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils' export const dynamic = 'force-dynamic' @@ -31,13 +28,6 @@ export const GET = withRouteHandler(async (request: NextRequest) => { const requestId = generateRequestId() try { - const session = await getSession() - - if (!session?.user?.id) { - logger.warn(`[${requestId}] Unauthenticated request rejected`) - return NextResponse.json({ error: 'User not authenticated' }, { status: 401 }) - } - const parsed = await parseRequest(wealthboxOAuthItemContract, request, {}) if (!parsed.success) return parsed.response const { credentialId, itemId, type } = parsed.data.query @@ -60,39 +50,18 @@ export const GET = withRouteHandler(async (request: NextRequest) => { return NextResponse.json({ error: itemIdValidation.error }, { status: 400 }) } - const resolved = await resolveOAuthAccountId(credentialId) - if (!resolved) { - return NextResponse.json({ error: 'Credential not found' }, { status: 404 }) - } - - if (resolved.workspaceId) { - const { getUserEntityPermissions } = await import('@/lib/workspaces/permissions/utils') - const perm = await getUserEntityPermissions( - session.user.id, - 'workspace', - resolved.workspaceId - ) - if (perm === null) { - return NextResponse.json({ error: 'Forbidden' }, { status: 403 }) - } - } - - const credentials = await db - .select() - .from(account) - .where(eq(account.id, resolved.accountId)) - .limit(1) - - if (!credentials.length) { - logger.warn(`[${requestId}] Credential not found`, { credentialId }) - return NextResponse.json({ error: 'Credential not found' }, { status: 404 }) + const credAccess = await authorizeCredentialUse(request, { + credentialId, + requireWorkflowIdForInternal: false, + }) + if (!credAccess.ok || !credAccess.credentialOwnerUserId) { + logger.warn(`[${requestId}] Credential access denied`, { error: credAccess.error }) + return NextResponse.json({ error: credAccess.error || 'Unauthorized' }, { status: 401 }) } - const accountRow = credentials[0] - const accessToken = await refreshAccessTokenIfNeeded( - resolved.accountId, - accountRow.userId, + credentialId, + credAccess.credentialOwnerUserId, requestId ) diff --git a/apps/sim/app/api/providers/route.ts b/apps/sim/app/api/providers/route.ts index a399a8399cb..ec5cfcba94e 100644 --- a/apps/sim/app/api/providers/route.ts +++ b/apps/sim/app/api/providers/route.ts @@ -6,6 +6,7 @@ import { eq } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { executeProviderContract } from '@/lib/api/contracts/providers' import { parseRequest } from '@/lib/api/server' +import { authorizeCredentialUse } from '@/lib/auth/credential-access' import { checkInternalAuth } from '@/lib/auth/hybrid' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' @@ -141,6 +142,21 @@ export const POST = withRouteHandler(async (request: NextRequest) => { let finalApiKey: string | undefined = apiKey try { if (provider === 'vertex' && vertexCredential) { + const vertexCredAccess = await authorizeCredentialUse(request, { + credentialId: vertexCredential, + workflowId: workflowId || undefined, + requireWorkflowIdForInternal: false, + }) + if (!vertexCredAccess.ok) { + logger.warn(`[${requestId}] Vertex credential access denied`, { + error: vertexCredAccess.error, + credentialId: vertexCredential, + }) + return NextResponse.json( + { error: vertexCredAccess.error || 'Unauthorized' }, + { status: 401 } + ) + } finalApiKey = await resolveVertexCredential(requestId, vertexCredential) } } catch (error) { diff --git a/apps/sim/app/api/resume/[workflowId]/[executionId]/[contextId]/route.ts b/apps/sim/app/api/resume/[workflowId]/[executionId]/[contextId]/route.ts index a9bab734a0f..3ece7a2fb2f 100644 --- a/apps/sim/app/api/resume/[workflowId]/[executionId]/[contextId]/route.ts +++ b/apps/sim/app/api/resume/[workflowId]/[executionId]/[contextId]/route.ts @@ -141,6 +141,7 @@ export const POST = withRouteHandler( try { const enqueueResult = await PauseResumeManager.enqueueOrStartResume({ executionId, + workflowId, contextId, resumeInput, userId, diff --git a/apps/sim/app/api/resume/poll/route.ts b/apps/sim/app/api/resume/poll/route.ts index 09f76ff7f15..12949569575 100644 --- a/apps/sim/app/api/resume/poll/route.ts +++ b/apps/sim/app/api/resume/poll/route.ts @@ -128,6 +128,7 @@ async function dispatchRow(row: DueRow, now: Date): Promise { try { const enqueueResult = await PauseResumeManager.enqueueOrStartResume({ executionId: row.executionId, + workflowId: row.workflowId, contextId: point.contextId, resumeInput: {}, userId, diff --git a/apps/sim/app/api/tools/gmail/label/route.ts b/apps/sim/app/api/tools/gmail/label/route.ts index d3a601ab65e..75cd890a522 100644 --- a/apps/sim/app/api/tools/gmail/label/route.ts +++ b/apps/sim/app/api/tools/gmail/label/route.ts @@ -1,21 +1,13 @@ -import { db } from '@sim/db' -import { account } from '@sim/db/schema' import { createLogger } from '@sim/logger' -import { eq } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { gmailLabelSelectorContract } from '@/lib/api/contracts/selectors/google' import { parseRequest } from '@/lib/api/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 { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { getScopesForService } from '@/lib/oauth/utils' -import { - getServiceAccountToken, - refreshAccessTokenIfNeeded, - resolveOAuthAccountId, - ServiceAccountTokenError, -} from '@/app/api/auth/oauth/utils' +import { refreshAccessTokenIfNeeded, ServiceAccountTokenError } from '@/app/api/auth/oauth/utils' export const dynamic = 'force-dynamic' @@ -25,13 +17,6 @@ export const GET = withRouteHandler(async (request: NextRequest) => { const requestId = generateRequestId() try { - const session = await getSession() - - if (!session?.user?.id) { - logger.warn(`[${requestId}] Unauthenticated label request rejected`) - return NextResponse.json({ error: 'User not authenticated' }, { status: 401 }) - } - const parsed = await parseRequest(gmailLabelSelectorContract, request, {}) if (!parsed.success) return parsed.response const { credentialId, labelId } = parsed.data.query @@ -43,56 +28,22 @@ export const GET = withRouteHandler(async (request: NextRequest) => { return NextResponse.json({ error: labelIdValidation.error }, { status: 400 }) } - const resolved = await resolveOAuthAccountId(credentialId) - if (!resolved) { - return NextResponse.json({ error: 'Credential not found' }, { status: 404 }) - } - - if (resolved.workspaceId) { - const { getUserEntityPermissions } = await import('@/lib/workspaces/permissions/utils') - const perm = await getUserEntityPermissions( - session.user.id, - 'workspace', - resolved.workspaceId - ) - if (perm === null) { - return NextResponse.json({ error: 'Forbidden' }, { status: 403 }) - } + const credAccess = await authorizeCredentialUse(request, { + credentialId, + requireWorkflowIdForInternal: false, + }) + if (!credAccess.ok || !credAccess.credentialOwnerUserId) { + logger.warn(`[${requestId}] Credential access denied`, { error: credAccess.error }) + return NextResponse.json({ error: credAccess.error || 'Unauthorized' }, { status: 401 }) } - let accessToken: string | null = null - - if (resolved.credentialType === 'service_account' && resolved.credentialId) { - accessToken = await getServiceAccountToken( - resolved.credentialId, - getScopesForService('gmail'), - impersonateEmail - ) - } else { - const credentials = await db - .select() - .from(account) - .where(eq(account.id, resolved.accountId)) - .limit(1) - - if (!credentials.length) { - logger.warn(`[${requestId}] Credential not found`) - return NextResponse.json({ error: 'Credential not found' }, { status: 404 }) - } - - const accountRow = credentials[0] - - logger.info( - `[${requestId}] Using credential: ${accountRow.id}, provider: ${accountRow.providerId}` - ) - - accessToken = await refreshAccessTokenIfNeeded( - resolved.accountId, - accountRow.userId, - requestId, - getScopesForService('gmail') - ) - } + const accessToken = await refreshAccessTokenIfNeeded( + credentialId, + credAccess.credentialOwnerUserId, + requestId, + getScopesForService('gmail'), + impersonateEmail + ) if (!accessToken) { return NextResponse.json({ error: 'Failed to obtain valid access token' }, { status: 401 }) diff --git a/apps/sim/app/api/tools/onedrive/files/route.ts b/apps/sim/app/api/tools/onedrive/files/route.ts index fa26c915f53..46a4afa9c70 100644 --- a/apps/sim/app/api/tools/onedrive/files/route.ts +++ b/apps/sim/app/api/tools/onedrive/files/route.ts @@ -1,15 +1,12 @@ -import { db } from '@sim/db' -import { account } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { generateId } from '@sim/utils/id' -import { eq } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { onedriveFilesQuerySchema } from '@/lib/api/contracts/selectors/microsoft' import { getValidationErrorMessage } from '@/lib/api/server' -import { getSession } from '@/lib/auth' +import { authorizeCredentialUse } from '@/lib/auth/credential-access' import { validateMicrosoftGraphId } from '@/lib/core/security/input-validation' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { refreshAccessTokenIfNeeded, resolveOAuthAccountId } from '@/app/api/auth/oauth/utils' +import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils' import type { MicrosoftGraphDriveItem } from '@/tools/onedrive/types' export const dynamic = 'force-dynamic' @@ -24,12 +21,6 @@ export const GET = withRouteHandler(async (request: NextRequest) => { logger.info(`[${requestId}] OneDrive files request received`) try { - const session = await getSession() - if (!session?.user?.id) { - logger.warn(`[${requestId}] Unauthenticated request rejected`) - return NextResponse.json({ error: 'User not authenticated' }, { status: 401 }) - } - const { searchParams } = new URL(request.url) const validation = onedriveFilesQuerySchema.safeParse({ credentialId: searchParams.get('credentialId') ?? '', @@ -53,38 +44,18 @@ export const GET = withRouteHandler(async (request: NextRequest) => { logger.info(`[${requestId}] Fetching credential`, { credentialId }) - const resolved = await resolveOAuthAccountId(credentialId) - if (!resolved) { - return NextResponse.json({ error: 'Credential not found' }, { status: 404 }) - } - - if (resolved.workspaceId) { - const { getUserEntityPermissions } = await import('@/lib/workspaces/permissions/utils') - const perm = await getUserEntityPermissions( - session.user.id, - 'workspace', - resolved.workspaceId - ) - if (perm === null) { - return NextResponse.json({ error: 'Forbidden' }, { status: 403 }) - } - } - - const credentials = await db - .select() - .from(account) - .where(eq(account.id, resolved.accountId)) - .limit(1) - if (!credentials.length) { - logger.warn(`[${requestId}] Credential not found`, { credentialId }) - return NextResponse.json({ error: 'Credential not found' }, { status: 404 }) + const credAccess = await authorizeCredentialUse(request, { + credentialId, + requireWorkflowIdForInternal: false, + }) + if (!credAccess.ok || !credAccess.credentialOwnerUserId) { + logger.warn(`[${requestId}] Credential access denied`, { error: credAccess.error }) + return NextResponse.json({ error: credAccess.error || 'Unauthorized' }, { status: 401 }) } - const accountRow = credentials[0] - const accessToken = await refreshAccessTokenIfNeeded( - resolved.accountId, - accountRow.userId, + credentialId, + credAccess.credentialOwnerUserId, requestId ) if (!accessToken) { diff --git a/apps/sim/app/api/tools/onedrive/folder/route.ts b/apps/sim/app/api/tools/onedrive/folder/route.ts index 1b44d5ce99a..1e0f4104651 100644 --- a/apps/sim/app/api/tools/onedrive/folder/route.ts +++ b/apps/sim/app/api/tools/onedrive/folder/route.ts @@ -1,15 +1,12 @@ -import { db } from '@sim/db' -import { account } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { generateId } from '@sim/utils/id' -import { eq } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { onedriveFolderQuerySchema } from '@/lib/api/contracts/selectors/microsoft' import { getValidationErrorMessage } from '@/lib/api/server' -import { getSession } from '@/lib/auth' +import { authorizeCredentialUse } from '@/lib/auth/credential-access' import { validateMicrosoftGraphId } from '@/lib/core/security/input-validation' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { refreshAccessTokenIfNeeded, resolveOAuthAccountId } from '@/app/api/auth/oauth/utils' +import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils' export const dynamic = 'force-dynamic' @@ -19,11 +16,6 @@ export const GET = withRouteHandler(async (request: NextRequest) => { const requestId = generateId().slice(0, 8) try { - const session = await getSession() - if (!session?.user?.id) { - return NextResponse.json({ error: 'User not authenticated' }, { status: 401 }) - } - const { searchParams } = new URL(request.url) const validation = onedriveFolderQuerySchema.safeParse({ credentialId: searchParams.get('credentialId') ?? '', @@ -42,37 +34,18 @@ export const GET = withRouteHandler(async (request: NextRequest) => { return NextResponse.json({ error: fileIdValidation.error }, { status: 400 }) } - const resolved = await resolveOAuthAccountId(credentialId) - if (!resolved) { - return NextResponse.json({ error: 'Credential not found' }, { status: 404 }) - } - - if (resolved.workspaceId) { - const { getUserEntityPermissions } = await import('@/lib/workspaces/permissions/utils') - const perm = await getUserEntityPermissions( - session.user.id, - 'workspace', - resolved.workspaceId - ) - if (perm === null) { - return NextResponse.json({ error: 'Forbidden' }, { status: 403 }) - } - } - - const credentials = await db - .select() - .from(account) - .where(eq(account.id, resolved.accountId)) - .limit(1) - if (!credentials.length) { - return NextResponse.json({ error: 'Credential not found' }, { status: 404 }) + const credAccess = await authorizeCredentialUse(request, { + credentialId, + requireWorkflowIdForInternal: false, + }) + if (!credAccess.ok || !credAccess.credentialOwnerUserId) { + logger.error(`[${requestId}] Credential access denied`, { error: credAccess.error }) + return NextResponse.json({ error: credAccess.error || 'Unauthorized' }, { status: 401 }) } - const accountRow = credentials[0] - const accessToken = await refreshAccessTokenIfNeeded( - resolved.accountId, - accountRow.userId, + credentialId, + credAccess.credentialOwnerUserId, requestId ) if (!accessToken) { diff --git a/apps/sim/app/api/tools/outlook/folders/route.ts b/apps/sim/app/api/tools/outlook/folders/route.ts index 7e56fe86ae9..cf4a638a7fd 100644 --- a/apps/sim/app/api/tools/outlook/folders/route.ts +++ b/apps/sim/app/api/tools/outlook/folders/route.ts @@ -1,16 +1,13 @@ -import { db } from '@sim/db' -import { account } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' -import { eq } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { outlookFoldersSelectorContract } from '@/lib/api/contracts/selectors/microsoft' import { parseRequest } from '@/lib/api/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 { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { refreshAccessTokenIfNeeded, resolveOAuthAccountId } from '@/app/api/auth/oauth/utils' +import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils' export const dynamic = 'force-dynamic' @@ -25,7 +22,6 @@ interface OutlookFolder { export const GET = withRouteHandler(async (request: NextRequest) => { try { - const session = await getSession() const parsed = await parseRequest(outlookFoldersSelectorContract, request, {}) if (!parsed.success) return parsed.response const { credentialId } = parsed.data.query @@ -37,49 +33,29 @@ export const GET = withRouteHandler(async (request: NextRequest) => { } try { - const sessionUserId = session?.user?.id || '' - - if (!sessionUserId) { - logger.error('No user ID found in session') - return NextResponse.json({ error: 'Authentication required' }, { status: 401 }) - } - - const resolved = await resolveOAuthAccountId(credentialId) - if (!resolved) { - return NextResponse.json({ error: 'Credential not found' }, { status: 404 }) - } - - if (resolved.workspaceId) { - const { getUserEntityPermissions } = await import('@/lib/workspaces/permissions/utils') - const perm = await getUserEntityPermissions( - session!.user!.id, - 'workspace', - resolved.workspaceId + const credAccess = await authorizeCredentialUse(request, { + credentialId, + requireWorkflowIdForInternal: false, + }) + if (!credAccess.ok || !credAccess.credentialOwnerUserId) { + logger.error('Credential access denied', { error: credAccess.error }) + return NextResponse.json( + { error: credAccess.error || 'Authentication required' }, + { status: 401 } ) - if (perm === null) { - return NextResponse.json({ error: 'Forbidden' }, { status: 403 }) - } } - const creds = await db - .select() - .from(account) - .where(eq(account.id, resolved.accountId)) - .limit(1) - if (!creds.length) { - logger.warn('Credential not found', { credentialId }) - return NextResponse.json({ error: 'Credential not found' }, { status: 404 }) - } - const credentialOwnerUserId = creds[0].userId - const accessToken = await refreshAccessTokenIfNeeded( - resolved.accountId, - credentialOwnerUserId, + credentialId, + credAccess.credentialOwnerUserId, generateRequestId() ) if (!accessToken) { - logger.error('Failed to get access token', { credentialId, userId: credentialOwnerUserId }) + logger.error('Failed to get access token', { + credentialId, + userId: credAccess.credentialOwnerUserId, + }) return NextResponse.json( { error: 'Could not retrieve access token', diff --git a/apps/sim/app/api/tools/wealthbox/item/route.ts b/apps/sim/app/api/tools/wealthbox/item/route.ts index fd9de60baba..066cfb6fcbe 100644 --- a/apps/sim/app/api/tools/wealthbox/item/route.ts +++ b/apps/sim/app/api/tools/wealthbox/item/route.ts @@ -1,15 +1,12 @@ -import { db } from '@sim/db' -import { account } from '@sim/db/schema' import { createLogger } from '@sim/logger' -import { eq } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { wealthboxItemContract } from '@/lib/api/contracts/selectors/wealthbox' import { parseRequest } from '@/lib/api/server' -import { getSession } from '@/lib/auth' +import { authorizeCredentialUse } from '@/lib/auth/credential-access' import { validatePathSegment } from '@/lib/core/security/input-validation' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { refreshAccessTokenIfNeeded, resolveOAuthAccountId } from '@/app/api/auth/oauth/utils' +import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils' export const dynamic = 'force-dynamic' @@ -19,13 +16,6 @@ export const GET = withRouteHandler(async (request: NextRequest) => { const requestId = generateRequestId() try { - const session = await getSession() - - if (!session?.user?.id) { - logger.warn(`[${requestId}] Unauthenticated request rejected`) - return NextResponse.json({ error: 'User not authenticated' }, { status: 401 }) - } - const parsed = await parseRequest(wealthboxItemContract, request, {}) if (!parsed.success) return parsed.response const { credentialId, itemId, type } = parsed.data.query @@ -54,39 +44,18 @@ export const GET = withRouteHandler(async (request: NextRequest) => { return NextResponse.json({ error: credentialIdValidation.error }, { status: 400 }) } - const resolved = await resolveOAuthAccountId(credentialId) - if (!resolved) { - return NextResponse.json({ error: 'Credential not found' }, { status: 404 }) - } - - if (resolved.workspaceId) { - const { getUserEntityPermissions } = await import('@/lib/workspaces/permissions/utils') - const perm = await getUserEntityPermissions( - session.user.id, - 'workspace', - resolved.workspaceId - ) - if (perm === null) { - return NextResponse.json({ error: 'Forbidden' }, { status: 403 }) - } - } - - const credentials = await db - .select() - .from(account) - .where(eq(account.id, resolved.accountId)) - .limit(1) - - if (!credentials.length) { - logger.warn(`[${requestId}] Credential not found`, { credentialId }) - return NextResponse.json({ error: 'Credential not found' }, { status: 404 }) + const credAccess = await authorizeCredentialUse(request, { + credentialId, + requireWorkflowIdForInternal: false, + }) + if (!credAccess.ok || !credAccess.credentialOwnerUserId) { + logger.warn(`[${requestId}] Credential access denied`, { error: credAccess.error }) + return NextResponse.json({ error: credAccess.error || 'Unauthorized' }, { status: 401 }) } - const accountRow = credentials[0] - const accessToken = await refreshAccessTokenIfNeeded( - resolved.accountId, - accountRow.userId, + credentialId, + credAccess.credentialOwnerUserId, requestId ) diff --git a/apps/sim/app/api/tools/wordpress/upload/route.ts b/apps/sim/app/api/tools/wordpress/upload/route.ts index b24274f7481..eee3296a299 100644 --- a/apps/sim/app/api/tools/wordpress/upload/route.ts +++ b/apps/sim/app/api/tools/wordpress/upload/route.ts @@ -11,6 +11,7 @@ import { processSingleFileToUserFile, } from '@/lib/uploads/utils/file-utils' import { downloadFileFromStorage } from '@/lib/uploads/utils/file-utils.server' +import { verifyFileAccess } from '@/app/api/files/authorization' export const dynamic = 'force-dynamic' @@ -78,6 +79,23 @@ export const POST = withRouteHandler(async (request: NextRequest) => { ) } + if (userFile.key && authResult.userId) { + const hasAccess = await verifyFileAccess(userFile.key, authResult.userId) + if (!hasAccess) { + logger.warn(`[${requestId}] File access denied for user`, { + userId: authResult.userId, + key: userFile.key, + }) + return NextResponse.json( + { + success: false, + error: 'File not found', + }, + { status: 404 } + ) + } + } + logger.info(`[${requestId}] Downloading file from storage`, { fileName: userFile.name, key: userFile.key, diff --git a/apps/sim/app/api/workflows/[id]/executions/[executionId]/cancel/route.ts b/apps/sim/app/api/workflows/[id]/executions/[executionId]/cancel/route.ts index 841b92c36fd..daeb032ddf4 100644 --- a/apps/sim/app/api/workflows/[id]/executions/[executionId]/cancel/route.ts +++ b/apps/sim/app/api/workflows/[id]/executions/[executionId]/cancel/route.ts @@ -22,10 +22,13 @@ const logger = createLogger('CancelExecutionAPI') const PAUSED_CANCELLATION_DB_ATTEMPTS = 3 const PAUSED_CANCELLATION_DB_RETRY_MS = 200 -async function completePausedCancellationWithRetry(executionId: string): Promise { +async function completePausedCancellationWithRetry( + executionId: string, + workflowId: string +): Promise { for (let attempt = 1; attempt <= PAUSED_CANCELLATION_DB_ATTEMPTS; attempt++) { try { - const cancelled = await PauseResumeManager.completePausedCancellation(executionId) + const cancelled = await PauseResumeManager.completePausedCancellation(executionId, workflowId) if (cancelled) { logger.info('Paused execution cancelled in database', { executionId, attempt }) return true @@ -129,7 +132,10 @@ export const POST = withRouteHandler( let pausedCancellationStarted = false let pausedCancelled = false try { - pausedCancellationStarted = await PauseResumeManager.beginPausedCancellation(executionId) + pausedCancellationStarted = await PauseResumeManager.beginPausedCancellation( + executionId, + workflowId + ) } catch (error) { logger.warn('Failed to begin paused execution cancellation in database', { executionId, @@ -138,7 +144,7 @@ export const POST = withRouteHandler( } const pendingPausedCancellation = pausedCancellationStarted ? null - : await PauseResumeManager.getPausedCancellationStatus(executionId) + : await PauseResumeManager.getPausedCancellationStatus(executionId, workflowId) const isPausedCancellationPath = pausedCancellationStarted || pendingPausedCancellation !== null @@ -161,22 +167,26 @@ export const POST = withRouteHandler( } if (!isPausedCancellationPath && (cancellation.durablyRecorded || locallyAborted)) { - await PauseResumeManager.blockQueuedResumesForCancellation(executionId).catch((error) => { - logger.warn('Failed to block queued paused resumes after cancellation', { - executionId, - error, - }) - }) - } else if (!isPausedCancellationPath) { - await PauseResumeManager.clearPausedCancellationIntent(executionId).catch((error) => { - logger.warn( - 'Failed to clear paused cancellation intent after unsuccessful cancellation', - { + await PauseResumeManager.blockQueuedResumesForCancellation(executionId, workflowId).catch( + (error) => { + logger.warn('Failed to block queued paused resumes after cancellation', { executionId, error, - } - ) - }) + }) + } + ) + } else if (!isPausedCancellationPath) { + await PauseResumeManager.clearPausedCancellationIntent(executionId, workflowId).catch( + (error) => { + logger.warn( + 'Failed to clear paused cancellation intent after unsuccessful cancellation', + { + executionId, + error, + } + ) + } + ) } let pausedCancellationPublished = false @@ -188,7 +198,7 @@ export const POST = withRouteHandler( ) pausedCancellationPublishFailed = !pausedCancellationPublished if (pausedCancellationPublished) { - pausedCancelled = await completePausedCancellationWithRetry(executionId) + pausedCancelled = await completePausedCancellationWithRetry(executionId, workflowId) } } else { if (pendingPausedCancellation === 'cancelled') { @@ -205,7 +215,7 @@ export const POST = withRouteHandler( ) pausedCancellationPublishFailed = !pausedCancellationPublished if (pausedCancellationPublished) { - pausedCancelled = await completePausedCancellationWithRetry(executionId) + pausedCancelled = await completePausedCancellationWithRetry(executionId, workflowId) } } } @@ -214,12 +224,14 @@ export const POST = withRouteHandler( pausedCancellationPublishFailed && (pausedCancellationStarted || pendingPausedCancellation === 'cancelling') ) { - await PauseResumeManager.clearPausedCancellationIntent(executionId).catch((error) => { - logger.warn('Failed to clear paused cancellation intent after publish failure', { - executionId, - error, - }) - }) + await PauseResumeManager.clearPausedCancellationIntent(executionId, workflowId).catch( + (error) => { + logger.warn('Failed to clear paused cancellation intent after publish failure', { + executionId, + error, + }) + } + ) } if ((cancellation.durablyRecorded || locallyAborted) && !pausedCancelled) { diff --git a/apps/sim/lib/workflows/executor/human-in-the-loop-manager.ts b/apps/sim/lib/workflows/executor/human-in-the-loop-manager.ts index 330fe93e14c..b56a9bba379 100644 --- a/apps/sim/lib/workflows/executor/human-in-the-loop-manager.ts +++ b/apps/sim/lib/workflows/executor/human-in-the-loop-manager.ts @@ -105,6 +105,8 @@ interface PersistPauseResultArgs { interface EnqueueResumeArgs { executionId: string + /** workflowId is used to scope the lookup to the correct tenant. Required unless the caller is a trusted internal cron. */ + workflowId?: string contextId: string resumeInput: unknown userId: string @@ -266,13 +268,20 @@ export class PauseResumeManager { } static async enqueueOrStartResume(args: EnqueueResumeArgs): Promise { - const { executionId, contextId, resumeInput, userId, allowedPauseKinds } = args + const { executionId, workflowId, contextId, resumeInput, userId, allowedPauseKinds } = args return await db.transaction(async (tx) => { const pausedExecution = await tx .select() .from(pausedExecutions) - .where(eq(pausedExecutions.executionId, executionId)) + .where( + workflowId + ? and( + eq(pausedExecutions.executionId, executionId), + eq(pausedExecutions.workflowId, workflowId) + ) + : eq(pausedExecutions.executionId, executionId) + ) .for('update') .limit(1) .then((rows) => rows[0]) @@ -1526,7 +1535,7 @@ export class PauseResumeManager { }) } - static async beginPausedCancellation(executionId: string): Promise { + static async beginPausedCancellation(executionId: string, workflowId?: string): Promise { const now = new Date() return await db.transaction(async (tx) => { @@ -1536,6 +1545,7 @@ export class PauseResumeManager { .where( and( eq(pausedExecutions.executionId, executionId), + ...(workflowId ? [eq(pausedExecutions.workflowId, workflowId)] : []), inArray(pausedExecutions.status, [...CANCELLABLE_PAUSED_STATUSES, 'cancelling']) ) ) @@ -1573,14 +1583,24 @@ export class PauseResumeManager { }) } - static async completePausedCancellation(executionId: string): Promise { + static async completePausedCancellation( + executionId: string, + workflowId?: string + ): Promise { const now = new Date() return await db.transaction(async (tx) => { const pausedExecution = await tx .select({ id: pausedExecutions.id, status: pausedExecutions.status }) .from(pausedExecutions) - .where(eq(pausedExecutions.executionId, executionId)) + .where( + workflowId + ? and( + eq(pausedExecutions.executionId, executionId), + eq(pausedExecutions.workflowId, workflowId) + ) + : eq(pausedExecutions.executionId, executionId) + ) .for('update') .limit(1) .then((rows) => rows[0]) @@ -1606,7 +1626,10 @@ export class PauseResumeManager { }) } - static async blockQueuedResumesForCancellation(executionId: string): Promise { + static async blockQueuedResumesForCancellation( + executionId: string, + workflowId?: string + ): Promise { const now = new Date() return await db.transaction(async (tx) => { @@ -1616,6 +1639,7 @@ export class PauseResumeManager { .where( and( eq(pausedExecutions.executionId, executionId), + ...(workflowId ? [eq(pausedExecutions.workflowId, workflowId)] : []), inArray(pausedExecutions.status, [...CANCELLABLE_PAUSED_STATUSES, 'cancelling']) ) ) @@ -1647,7 +1671,10 @@ export class PauseResumeManager { }) } - static async clearPausedCancellationIntent(executionId: string): Promise { + static async clearPausedCancellationIntent( + executionId: string, + workflowId?: string + ): Promise { const now = new Date() await db .update(pausedExecutions) @@ -1658,6 +1685,7 @@ export class PauseResumeManager { .where( and( eq(pausedExecutions.executionId, executionId), + ...(workflowId ? [eq(pausedExecutions.workflowId, workflowId)] : []), eq(pausedExecutions.status, 'cancelling') ) ) @@ -1665,7 +1693,8 @@ export class PauseResumeManager { } static async getPausedCancellationStatus( - executionId: string + executionId: string, + workflowId?: string ): Promise<'cancelling' | 'cancelled' | null> { const activeResume = await db .select({ id: resumeQueue.id }) @@ -1681,7 +1710,14 @@ export class PauseResumeManager { const pausedExecution = await db .select({ status: pausedExecutions.status }) .from(pausedExecutions) - .where(eq(pausedExecutions.executionId, executionId)) + .where( + workflowId + ? and( + eq(pausedExecutions.executionId, executionId), + eq(pausedExecutions.workflowId, workflowId) + ) + : eq(pausedExecutions.executionId, executionId) + ) .limit(1) .then((rows) => rows[0]) @@ -1838,7 +1874,7 @@ export class PauseResumeManager { } } - static async processQueuedResumes(parentExecutionId: string): Promise { + static async processQueuedResumes(parentExecutionId: string, workflowId?: string): Promise { let pendingEntry: { entry: typeof resumeQueue.$inferSelect pausedExecution: typeof pausedExecutions.$inferSelect @@ -1849,7 +1885,14 @@ export class PauseResumeManager { const pausedExecution = await tx .select() .from(pausedExecutions) - .where(eq(pausedExecutions.executionId, parentExecutionId)) + .where( + workflowId + ? and( + eq(pausedExecutions.executionId, parentExecutionId), + eq(pausedExecutions.workflowId, workflowId) + ) + : eq(pausedExecutions.executionId, parentExecutionId) + ) .for('update') .limit(1) .then((rows) => rows[0]) From 94bc2e204cb0f597cce8f5b167b92ff4e5d89575 Mon Sep 17 00:00:00 2001 From: waleed Date: Sun, 10 May 2026 18:19:11 -0700 Subject: [PATCH 2/7] fix(security): close verifyFileAccess bypass, thread workflowId to processQueuedResumes, fix log level - Fail closed in WordPress upload when userFile.key is present but authResult.userId is absent, preventing silent bypass of ownership check via JWT fallback path - Thread workflowId into processQueuedResumes in the async resume error-recovery path and in pause-persistence.ts to close residual cross-tenant gap - Change logger.error to logger.warn for credential access denial in OneDrive folder route to match all other routes in this PR --- .../[executionId]/[contextId]/route.ts | 2 +- apps/sim/app/api/tools/onedrive/folder/route.ts | 2 +- apps/sim/app/api/tools/wordpress/upload/route.ts | 14 ++++++-------- .../lib/workflows/executor/pause-persistence.ts | 2 +- 4 files changed, 9 insertions(+), 11 deletions(-) diff --git a/apps/sim/app/api/resume/[workflowId]/[executionId]/[contextId]/route.ts b/apps/sim/app/api/resume/[workflowId]/[executionId]/[contextId]/route.ts index 3ece7a2fb2f..38e263d29e6 100644 --- a/apps/sim/app/api/resume/[workflowId]/[executionId]/[contextId]/route.ts +++ b/apps/sim/app/api/resume/[workflowId]/[executionId]/[contextId]/route.ts @@ -250,7 +250,7 @@ export const POST = withRouteHandler( contextId: enqueueResult.contextId, failureReason: 'Failed to queue async resume execution', }) - await PauseResumeManager.processQueuedResumes(executionId) + await PauseResumeManager.processQueuedResumes(executionId, workflowId) return NextResponse.json( { error: 'Failed to queue resume execution. Please try again.' }, { status: 503 } diff --git a/apps/sim/app/api/tools/onedrive/folder/route.ts b/apps/sim/app/api/tools/onedrive/folder/route.ts index 1e0f4104651..17ff0b02d8e 100644 --- a/apps/sim/app/api/tools/onedrive/folder/route.ts +++ b/apps/sim/app/api/tools/onedrive/folder/route.ts @@ -39,7 +39,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => { requireWorkflowIdForInternal: false, }) if (!credAccess.ok || !credAccess.credentialOwnerUserId) { - logger.error(`[${requestId}] Credential access denied`, { error: credAccess.error }) + logger.warn(`[${requestId}] Credential access denied`, { error: credAccess.error }) return NextResponse.json({ error: credAccess.error || 'Unauthorized' }, { status: 401 }) } diff --git a/apps/sim/app/api/tools/wordpress/upload/route.ts b/apps/sim/app/api/tools/wordpress/upload/route.ts index eee3296a299..8ccfaae647c 100644 --- a/apps/sim/app/api/tools/wordpress/upload/route.ts +++ b/apps/sim/app/api/tools/wordpress/upload/route.ts @@ -79,20 +79,18 @@ export const POST = withRouteHandler(async (request: NextRequest) => { ) } - if (userFile.key && authResult.userId) { + if (userFile.key) { + if (!authResult.userId) { + logger.warn(`[${requestId}] File access check requires userId but none available`) + return NextResponse.json({ success: false, error: 'File not found' }, { status: 404 }) + } const hasAccess = await verifyFileAccess(userFile.key, authResult.userId) if (!hasAccess) { logger.warn(`[${requestId}] File access denied for user`, { userId: authResult.userId, key: userFile.key, }) - return NextResponse.json( - { - success: false, - error: 'File not found', - }, - { status: 404 } - ) + return NextResponse.json({ success: false, error: 'File not found' }, { status: 404 }) } } diff --git a/apps/sim/lib/workflows/executor/pause-persistence.ts b/apps/sim/lib/workflows/executor/pause-persistence.ts index 57036dccf6f..2080668cccd 100644 --- a/apps/sim/lib/workflows/executor/pause-persistence.ts +++ b/apps/sim/lib/workflows/executor/pause-persistence.ts @@ -54,7 +54,7 @@ export async function handlePostExecutionPauseState({ } } else { try { - await PauseResumeManager.processQueuedResumes(executionId) + await PauseResumeManager.processQueuedResumes(executionId, workflowId) } catch (resumeError) { logger.error('Failed to process queued resumes', { executionId, From fd234acb08c888da1793edfd86119f00214ade37 Mon Sep 17 00:00:00 2001 From: waleed Date: Sun, 10 May 2026 18:33:04 -0700 Subject: [PATCH 3/7] fix(security): thread workflowId through all processQueuedResumes call sites MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes residual cross-tenant IDOR gap where processQueuedResumes was called without a workflowId scope in persistPauseResult, startResumeExecution (success and error paths), and clearPausedCancellationIntent. workflowId was already in scope at each site — this wires it through to the existing optional parameter. --- .../executor/human-in-the-loop-manager.ts | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/apps/sim/lib/workflows/executor/human-in-the-loop-manager.ts b/apps/sim/lib/workflows/executor/human-in-the-loop-manager.ts index b56a9bba379..540d4ef8f74 100644 --- a/apps/sim/lib/workflows/executor/human-in-the-loop-manager.ts +++ b/apps/sim/lib/workflows/executor/human-in-the-loop-manager.ts @@ -264,7 +264,7 @@ export class PauseResumeManager { .where(eq(pausedExecutions.id, existing.id)) }) - await PauseResumeManager.processQueuedResumes(executionId) + await PauseResumeManager.processQueuedResumes(executionId, workflowId) } static async enqueueOrStartResume(args: EnqueueResumeArgs): Promise { @@ -504,7 +504,10 @@ export class PauseResumeManager { }) } - await PauseResumeManager.processQueuedResumes(pausedExecution.executionId) + await PauseResumeManager.processQueuedResumes( + pausedExecution.executionId, + pausedExecution.workflowId + ) return result } catch (error) { @@ -532,7 +535,10 @@ export class PauseResumeManager { contextId, error, }) - await PauseResumeManager.processQueuedResumes(pausedExecution.executionId) + await PauseResumeManager.processQueuedResumes( + pausedExecution.executionId, + pausedExecution.workflowId + ) throw error } } @@ -1689,7 +1695,7 @@ export class PauseResumeManager { eq(pausedExecutions.status, 'cancelling') ) ) - await PauseResumeManager.processQueuedResumes(executionId) + await PauseResumeManager.processQueuedResumes(executionId, workflowId) } static async getPausedCancellationStatus( From 0d4c9c6f759018f741377f6eb10b4496d381d364 Mon Sep 17 00:00:00 2001 From: waleed Date: Sun, 10 May 2026 18:47:30 -0700 Subject: [PATCH 4/7] fix(security): remove any types, drop extraneous comments, normalize caught errors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - catch (error: any) → catch (error) + toError(error).message in resume and cancel routes - Remove what-not-why inline comments from wordpress upload and onedrive/files routes - Remove redundant debug-only item breakdown log and the file-IDs log in onedrive/files - Trim extraneous DAG-edge comments from updateSnapshotAfterResume in HITL manager --- .../[executionId]/[contextId]/route.ts | 4 +- .../sim/app/api/tools/onedrive/files/route.ts | 39 +++---------------- .../app/api/tools/wordpress/upload/route.ts | 5 --- .../executions/[executionId]/cancel/route.ts | 11 ++++-- .../executor/human-in-the-loop-manager.ts | 6 --- 5 files changed, 15 insertions(+), 50 deletions(-) diff --git a/apps/sim/app/api/resume/[workflowId]/[executionId]/[contextId]/route.ts b/apps/sim/app/api/resume/[workflowId]/[executionId]/[contextId]/route.ts index 38e263d29e6..ff70c6f1898 100644 --- a/apps/sim/app/api/resume/[workflowId]/[executionId]/[contextId]/route.ts +++ b/apps/sim/app/api/resume/[workflowId]/[executionId]/[contextId]/route.ts @@ -284,7 +284,7 @@ export const POST = withRouteHandler( executionId: enqueueResult.resumeExecutionId, message: 'Resume execution started.', }) - } catch (error: any) { + } catch (error) { logger.error('Resume request failed', { workflowId, executionId, @@ -292,7 +292,7 @@ export const POST = withRouteHandler( error, }) return NextResponse.json( - { error: error.message || 'Failed to queue resume request' }, + { error: toError(error).message || 'Failed to queue resume request' }, { status: 400 } ) } diff --git a/apps/sim/app/api/tools/onedrive/files/route.ts b/apps/sim/app/api/tools/onedrive/files/route.ts index 46a4afa9c70..4b3b7273608 100644 --- a/apps/sim/app/api/tools/onedrive/files/route.ts +++ b/apps/sim/app/api/tools/onedrive/files/route.ts @@ -63,11 +63,9 @@ export const GET = withRouteHandler(async (request: NextRequest) => { return NextResponse.json({ error: 'Failed to obtain valid access token' }, { status: 401 }) } - // Use search endpoint if query provided, otherwise list root children - // Microsoft Graph API doesn't support $filter on file/folder properties for /children endpoint + // $filter is unsupported on the /children endpoint; use search when a query is present let url: string if (query) { - // Use search endpoint with query const searchParams_new = new URLSearchParams() searchParams_new.append( '$select', @@ -76,7 +74,6 @@ export const GET = withRouteHandler(async (request: NextRequest) => { searchParams_new.append('$top', '50') url = `https://graph.microsoft.com/v1.0/me/drive/root/search(q='${encodeURIComponent(query)}')?${searchParams_new.toString()}` } else { - // List all children (files and folders) from root const searchParams_new = new URLSearchParams() searchParams_new.append( '$select', @@ -109,27 +106,8 @@ export const GET = withRouteHandler(async (request: NextRequest) => { const data = await response.json() logger.info(`[${requestId}] Received ${data.value?.length || 0} items from Microsoft Graph`) - // Log what we received to debug filtering - const itemBreakdown = (data.value || []).reduce( - (acc: any, item: MicrosoftGraphDriveItem) => { - if (item.file) acc.files++ - if (item.folder) acc.folders++ - return acc - }, - { files: 0, folders: 0 } - ) - logger.info(`[${requestId}] Item breakdown`, itemBreakdown) - const files = (data.value || []) - .filter((item: MicrosoftGraphDriveItem) => { - const isFile = !!item.file && !item.folder - if (!isFile) { - logger.debug( - `[${requestId}] Filtering out item: ${item.name} (isFolder: ${!!item.folder})` - ) - } - return isFile - }) + .filter((item: MicrosoftGraphDriveItem) => !!item.file && !item.folder) .map((file: MicrosoftGraphDriveItem) => ({ id: file.id, name: file.name, @@ -150,16 +128,9 @@ export const GET = withRouteHandler(async (request: NextRequest) => { : [], })) - logger.info( - `[${requestId}] Returning ${files.length} files (filtered from ${data.value?.length || 0} items)` - ) - - // Log the file IDs we're returning - if (files.length > 0) { - logger.info(`[${requestId}] File IDs being returned:`, { - fileIds: files.slice(0, 5).map((f: any) => ({ id: f.id, name: f.name })), - }) - } + logger.info(`[${requestId}] Returning ${files.length} files`, { + totalItems: data.value?.length || 0, + }) return NextResponse.json({ files }, { status: 200 }) } catch (error) { diff --git a/apps/sim/app/api/tools/wordpress/upload/route.ts b/apps/sim/app/api/tools/wordpress/upload/route.ts index 8ccfaae647c..77a437c0957 100644 --- a/apps/sim/app/api/tools/wordpress/upload/route.ts +++ b/apps/sim/app/api/tools/wordpress/upload/route.ts @@ -63,7 +63,6 @@ export const POST = withRouteHandler(async (request: NextRequest) => { ) } - // Process file - convert to UserFile format if needed const fileData = validatedData.file let userFile @@ -115,7 +114,6 @@ export const POST = withRouteHandler(async (request: NextRequest) => { ) } - // Use provided filename or fall back to the original file name const filename = validatedData.filename || userFile.name const mimeType = userFile.type || getMimeTypeFromExtension(getFileExtension(filename)) @@ -126,14 +124,11 @@ export const POST = withRouteHandler(async (request: NextRequest) => { size: fileBuffer.length, }) - // Upload to WordPress using multipart form data const formData = new FormData() - // Convert Buffer to Uint8Array for Blob compatibility const uint8Array = new Uint8Array(fileBuffer) const blob = new Blob([uint8Array], { type: mimeType }) formData.append('file', blob, filename) - // Add optional metadata if (validatedData.title) { formData.append('title', validatedData.title) } diff --git a/apps/sim/app/api/workflows/[id]/executions/[executionId]/cancel/route.ts b/apps/sim/app/api/workflows/[id]/executions/[executionId]/cancel/route.ts index daeb032ddf4..02fab158465 100644 --- a/apps/sim/app/api/workflows/[id]/executions/[executionId]/cancel/route.ts +++ b/apps/sim/app/api/workflows/[id]/executions/[executionId]/cancel/route.ts @@ -1,6 +1,7 @@ import { db } from '@sim/db' import { workflowExecutionLogs } from '@sim/db/schema' import { createLogger } from '@sim/logger' +import { toError } from '@sim/utils/errors' import { sleep } from '@sim/utils/helpers' import { authorizeWorkflowByWorkspacePermission } from '@sim/workflow-authz' import { and, eq } from 'drizzle-orm' @@ -293,10 +294,14 @@ export const POST = withRouteHandler( pausedCancelled, reason, }) - } catch (error: any) { - logger.error('Failed to cancel execution', { workflowId, executionId, error: error.message }) + } catch (error) { + logger.error('Failed to cancel execution', { + workflowId, + executionId, + error: toError(error).message, + }) return NextResponse.json( - { error: error.message || 'Failed to cancel execution' }, + { error: toError(error).message || 'Failed to cancel execution' }, { status: 500 } ) } diff --git a/apps/sim/lib/workflows/executor/human-in-the-loop-manager.ts b/apps/sim/lib/workflows/executor/human-in-the-loop-manager.ts index 540d4ef8f74..0143e13ebaa 100644 --- a/apps/sim/lib/workflows/executor/human-in-the-loop-manager.ts +++ b/apps/sim/lib/workflows/executor/human-in-the-loop-manager.ts @@ -1482,10 +1482,7 @@ export class PauseResumeManager { snapshotData.state = executionState } - // Update the DAG incoming edges in the snapshot - // Remove the edge from the resumed pause block if (snapshotData.state) { - // Track completed pause contexts so future resumes remove their edges const completedPauseContexts = new Set( (snapshotData.state.completedPauseContexts ?? []).map((id: string) => PauseResumeManager.normalizePauseBlockId(id) @@ -1497,7 +1494,6 @@ export class PauseResumeManager { const dagIncomingEdges = snapshotData.state.dagIncomingEdges if (dagIncomingEdges) { - // Find all edges from the resumed pause block and remove them from targets const workflowData = snapshotData.workflow const connections = workflowData.connections || [] @@ -1505,7 +1501,6 @@ export class PauseResumeManager { if (conn.source === pauseBlockId) { const targetId = conn.target if (dagIncomingEdges[targetId]) { - // Remove this source from the target's incoming edges dagIncomingEdges[targetId] = dagIncomingEdges[targetId].filter( (sourceId: string) => sourceId !== pauseBlockId ) @@ -1521,7 +1516,6 @@ export class PauseResumeManager { } } - // Update the snapshot in the database const updatedSnapshot: SerializedSnapshot = { snapshot: JSON.stringify(snapshotData), triggerIds: currentSnapshot.triggerIds, From eb1cf3f7ba349bd42311fb9cbfe3cf840738bc50 Mon Sep 17 00:00:00 2001 From: waleed Date: Sun, 10 May 2026 19:38:01 -0700 Subject: [PATCH 5/7] fix: use logger.warn for credential access denial in outlook folders route --- apps/sim/app/api/tools/outlook/folders/route.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/sim/app/api/tools/outlook/folders/route.ts b/apps/sim/app/api/tools/outlook/folders/route.ts index cf4a638a7fd..2cd0addcd85 100644 --- a/apps/sim/app/api/tools/outlook/folders/route.ts +++ b/apps/sim/app/api/tools/outlook/folders/route.ts @@ -38,7 +38,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => { requireWorkflowIdForInternal: false, }) if (!credAccess.ok || !credAccess.credentialOwnerUserId) { - logger.error('Credential access denied', { error: credAccess.error }) + logger.warn('Credential access denied', { error: credAccess.error }) return NextResponse.json( { error: credAccess.error || 'Authentication required' }, { status: 401 } From a60057aca777e28d16f26903b67de13a8f8a5645 Mon Sep 17 00:00:00 2001 From: waleed Date: Sun, 10 May 2026 20:16:01 -0700 Subject: [PATCH 6/7] fix(security): make workflowId required in all HITL pause/resume methods MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit All 7 method signatures (processQueuedResumes, enqueueOrStartResume, beginPausedCancellation, completePausedCancellation, blockQueuedResumesForCancellation, clearPausedCancellationIntent, getPausedCancellationStatus) previously accepted workflowId as optional. Every call site already supplies it — making it required closes the vulnerability at the type level so future callers cannot accidentally omit tenant scoping and silently fall back to an unscoped DB query. --- .../executor/human-in-the-loop-manager.ts | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/apps/sim/lib/workflows/executor/human-in-the-loop-manager.ts b/apps/sim/lib/workflows/executor/human-in-the-loop-manager.ts index 0143e13ebaa..8a58070a599 100644 --- a/apps/sim/lib/workflows/executor/human-in-the-loop-manager.ts +++ b/apps/sim/lib/workflows/executor/human-in-the-loop-manager.ts @@ -105,8 +105,7 @@ interface PersistPauseResultArgs { interface EnqueueResumeArgs { executionId: string - /** workflowId is used to scope the lookup to the correct tenant. Required unless the caller is a trusted internal cron. */ - workflowId?: string + workflowId: string contextId: string resumeInput: unknown userId: string @@ -1535,7 +1534,7 @@ export class PauseResumeManager { }) } - static async beginPausedCancellation(executionId: string, workflowId?: string): Promise { + static async beginPausedCancellation(executionId: string, workflowId: string): Promise { const now = new Date() return await db.transaction(async (tx) => { @@ -1585,7 +1584,7 @@ export class PauseResumeManager { static async completePausedCancellation( executionId: string, - workflowId?: string + workflowId: string ): Promise { const now = new Date() @@ -1628,7 +1627,7 @@ export class PauseResumeManager { static async blockQueuedResumesForCancellation( executionId: string, - workflowId?: string + workflowId: string ): Promise { const now = new Date() @@ -1673,7 +1672,7 @@ export class PauseResumeManager { static async clearPausedCancellationIntent( executionId: string, - workflowId?: string + workflowId: string ): Promise { const now = new Date() await db @@ -1694,7 +1693,7 @@ export class PauseResumeManager { static async getPausedCancellationStatus( executionId: string, - workflowId?: string + workflowId: string ): Promise<'cancelling' | 'cancelled' | null> { const activeResume = await db .select({ id: resumeQueue.id }) @@ -1874,7 +1873,7 @@ export class PauseResumeManager { } } - static async processQueuedResumes(parentExecutionId: string, workflowId?: string): Promise { + static async processQueuedResumes(parentExecutionId: string, workflowId: string): Promise { let pendingEntry: { entry: typeof resumeQueue.$inferSelect pausedExecution: typeof pausedExecutions.$inferSelect From 86f3a532518d01276995d0c8e11177a4865fd89a Mon Sep 17 00:00:00 2001 From: waleed Date: Sun, 10 May 2026 20:36:06 -0700 Subject: [PATCH 7/7] fix(security): thread workflowId through internal HITL cancellation calls and remove dead branches in credential-access --- apps/sim/lib/auth/credential-access.ts | 136 +++++++++--------- .../executor/human-in-the-loop-manager.ts | 8 +- 2 files changed, 70 insertions(+), 74 deletions(-) diff --git a/apps/sim/lib/auth/credential-access.ts b/apps/sim/lib/auth/credential-access.ts index 928e39671ff..05e017c87a3 100644 --- a/apps/sim/lib/auth/credential-access.ts +++ b/apps/sim/lib/auth/credential-access.ts @@ -78,44 +78,40 @@ export async function authorizeCredentialUse( return { ok: false, error: 'Credential is not accessible from this workflow workspace' } } - if (actingUserId) { - const requesterPerm = await getUserEntityPermissions( - actingUserId, - 'workspace', - platformCredential.workspaceId - ) + const requesterPerm = await getUserEntityPermissions( + actingUserId, + 'workspace', + platformCredential.workspaceId + ) - const [membership] = await db - .select({ id: credentialMember.id }) - .from(credentialMember) - .where( - and( - eq(credentialMember.credentialId, platformCredential.id), - eq(credentialMember.userId, actingUserId), - eq(credentialMember.status, 'active') - ) + const [membership] = await db + .select({ id: credentialMember.id }) + .from(credentialMember) + .where( + and( + eq(credentialMember.credentialId, platformCredential.id), + eq(credentialMember.userId, actingUserId), + eq(credentialMember.status, 'active') ) - .limit(1) - - if (!membership) { - return { - ok: false, - error: - 'You do not have access to this credential. Ask the credential admin to add you as a member.', - } - } - if (requesterPerm === null) { - return { ok: false, error: 'You do not have access to this workspace.' } + ) + .limit(1) + + if (!membership) { + return { + ok: false, + error: + 'You do not have access to this credential. Ask the credential admin to add you as a member.', } - } else if (!workflowContext) { - return { ok: false, error: 'workflowId is required' } + } + if (requesterPerm === null) { + return { ok: false, error: 'You do not have access to this workspace.' } } return { ok: true, authType: auth.authType as CredentialAccessResult['authType'], requesterUserId: auth.userId, - credentialOwnerUserId: actingUserId || auth.userId, + credentialOwnerUserId: actingUserId, workspaceId: platformCredential.workspaceId, resolvedCredentialId: platformCredential.id, } @@ -139,36 +135,34 @@ export async function authorizeCredentialUse( return { ok: false, error: 'Credential account not found' } } - if (actingUserId) { - const requesterPerm = await getUserEntityPermissions( - actingUserId, - 'workspace', - platformCredential.workspaceId - ) + const requesterPerm = await getUserEntityPermissions( + actingUserId, + 'workspace', + platformCredential.workspaceId + ) - const [membership] = await db - .select({ id: credentialMember.id }) - .from(credentialMember) - .where( - and( - eq(credentialMember.credentialId, platformCredential.id), - eq(credentialMember.userId, actingUserId), - eq(credentialMember.status, 'active') - ) + const [membership] = await db + .select({ id: credentialMember.id }) + .from(credentialMember) + .where( + and( + eq(credentialMember.credentialId, platformCredential.id), + eq(credentialMember.userId, actingUserId), + eq(credentialMember.status, 'active') ) - .limit(1) + ) + .limit(1) - if (!membership) { - return { - ok: false, - error: `You do not have access to this credential. Ask the credential admin to add you as a member.`, - } + if (!membership) { + return { + ok: false, + error: `You do not have access to this credential. Ask the credential admin to add you as a member.`, } - if (requesterPerm === null) { - return { - ok: false, - error: 'You do not have access to this workspace.', - } + } + if (requesterPerm === null) { + return { + ok: false, + error: 'You do not have access to this workspace.', } } @@ -222,25 +216,23 @@ export async function authorizeCredentialUse( return { ok: false, error: 'Credential account not found' } } - if (actingUserId) { - const [membership] = await db - .select({ id: credentialMember.id }) - .from(credentialMember) - .where( - and( - eq(credentialMember.credentialId, workspaceCredential.id), - eq(credentialMember.userId, actingUserId), - eq(credentialMember.status, 'active') - ) + const [membership] = await db + .select({ id: credentialMember.id }) + .from(credentialMember) + .where( + and( + eq(credentialMember.credentialId, workspaceCredential.id), + eq(credentialMember.userId, actingUserId), + eq(credentialMember.status, 'active') ) - .limit(1) + ) + .limit(1) - if (!membership) { - return { - ok: false, - error: - 'You do not have access to this credential. Ask the credential admin to add you as a member.', - } + if (!membership) { + return { + ok: false, + error: + 'You do not have access to this credential. Ask the credential admin to add you as a member.', } } diff --git a/apps/sim/lib/workflows/executor/human-in-the-loop-manager.ts b/apps/sim/lib/workflows/executor/human-in-the-loop-manager.ts index 8a58070a599..3392a2e7bb8 100644 --- a/apps/sim/lib/workflows/executor/human-in-the-loop-manager.ts +++ b/apps/sim/lib/workflows/executor/human-in-the-loop-manager.ts @@ -476,10 +476,14 @@ export class PauseResumeManager { failureReason: 'Resume execution cancelled', }) const pausedCancellationStatus = await PauseResumeManager.getPausedCancellationStatus( - pausedExecution.executionId + pausedExecution.executionId, + pausedExecution.workflowId ) if (pausedCancellationStatus === 'cancelling') { - await PauseResumeManager.completePausedCancellation(pausedExecution.executionId) + await PauseResumeManager.completePausedCancellation( + pausedExecution.executionId, + pausedExecution.workflowId + ) } } else { await PauseResumeManager.updateSnapshotAfterResume({