Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
398dc65
fix(security): harden HIGH deepsec findings across multiple attack su…
waleedlatif1 May 12, 2026
7b84a79
fix(security): eliminate workspace env lost-update race with atomic J…
waleedlatif1 May 12, 2026
7b6e43d
fix(security): address audit findings from security fix review
waleedlatif1 May 12, 2026
2d86b7b
fix(security): address PR review comments and harden deepsec fixes
waleedlatif1 May 12, 2026
ba4738b
fix(workflows): fix VariableType assignment in admin workflow import …
waleedlatif1 May 12, 2026
7e1104e
fix(a2a): handle Request objects in pinnedFetch URL extraction
waleedlatif1 May 12, 2026
b7d57af
fix(security): extract shared file-access guard; merge workspace/moth…
waleedlatif1 May 12, 2026
1fdb0df
fix(security): advisory lock for env first-insert race; handle all Bo…
waleedlatif1 May 12, 2026
feea2e3
chore: remove inline comment from advisory lock
waleedlatif1 May 12, 2026
e1a37d7
fix(security): remove stray comment; narrow credentialType to literal…
waleedlatif1 May 12, 2026
5719fd6
fix(security): add credentialId validation to wealthbox oauth route; …
waleedlatif1 May 12, 2026
988ce33
fix(security): stream A2A response body to unblock SSE; keep text/jso…
waleedlatif1 May 12, 2026
e56cb1c
fix(security): resolve credentialId guard on OneDrive, use assertTool…
waleedlatif1 May 12, 2026
7152789
fix(security): handle string[][] HeadersInit format in pinnedFetch
waleedlatif1 May 12, 2026
5940ed2
fix(security): keep abort listener alive during body streaming; clean…
waleedlatif1 May 13, 2026
0073513
chore: remove extraneous inline comment
waleedlatif1 May 13, 2026
31e97df
fix(security): cleanup abort listener when maxResponseBytes limit is …
waleedlatif1 May 13, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 21 additions & 40 deletions apps/sim/app/api/auth/oauth/wealthbox/items/route.ts
Original file line number Diff line number Diff line change
@@ -1,14 +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 { wealthboxOAuthItemsContract } 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'

Expand All @@ -30,51 +28,34 @@ 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(wealthboxOAuthItemsContract, request, {})
if (!parsed.success) return parsed.response
const { credentialId, type } = parsed.data.query
const query = parsed.data.query.query ?? ''

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 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 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 authz = await authorizeCredentialUse(request, {
credentialId,
requireWorkflowIdForInternal: false,
})
if (!authz.ok || !authz.credentialOwnerUserId) {
return NextResponse.json({ error: authz.error || 'Unauthorized' }, { status: 403 })
Comment thread
waleedlatif1 marked this conversation as resolved.
}

const accountRow = credentials[0]

const accessToken = await refreshAccessTokenIfNeeded(
resolved.accountId,
accountRow.userId,
credentialId,
authz.credentialOwnerUserId,
requestId
)

Expand Down
31 changes: 31 additions & 0 deletions apps/sim/app/api/files/authorization.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { db } from '@sim/db'
import { document, knowledgeBase, workspaceFile } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, eq, isNull, like, or } from 'drizzle-orm'
import { NextResponse } from 'next/server'
import { getFileMetadata } from '@/lib/uploads'
import type { StorageContext } from '@/lib/uploads/config'
import { BLOB_CHAT_CONFIG, S3_CHAT_CONFIG } from '@/lib/uploads/config'
Expand Down Expand Up @@ -587,6 +588,36 @@ async function authorizeFileAccess(
}
}

/**
* Guard helper for tool routes that download user files from storage.
*
* Validates that `key` is a non-empty string, that `userId` is present, and
* that the authenticated user owns the file. Returns a 404 `NextResponse` on
* any failure so callers can `return` it immediately; returns `null` when
* access is granted.
*/
export async function assertToolFileAccess(
key: unknown,
userId: string | undefined,
requestId: string,
routeLogger: ReturnType<typeof createLogger>
): Promise<NextResponse | null> {
if (typeof key !== 'string' || key.length === 0) {
routeLogger.warn(`[${requestId}] File access check rejected: missing key`)
return NextResponse.json({ success: false, error: 'File not found' }, { status: 404 })
}
if (!userId) {
routeLogger.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(key, userId)
if (!hasAccess) {
routeLogger.warn(`[${requestId}] File access denied for user`, { userId, key })
return NextResponse.json({ success: false, error: 'File not found' }, { status: 404 })
}
return null
}

/**
* Get chat storage configuration based on current storage provider
*/
Expand Down
7 changes: 1 addition & 6 deletions apps/sim/app/api/files/multipart/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -136,7 +136,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
const config = getStorageConfig(storageContext)

let customKey: string | undefined
if (context === 'workspace') {
if (context === 'workspace' || context === 'mothership') {
const { MAX_WORKSPACE_FILE_SIZE } = await import('@/lib/uploads/shared/types')
if (typeof fileSize === 'number' && fileSize > MAX_WORKSPACE_FILE_SIZE) {
return NextResponse.json(
Expand All @@ -158,11 +158,6 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
{ status: 413 }
)
}
Comment thread
waleedlatif1 marked this conversation as resolved.
} else if (context === 'mothership') {
const { generateWorkspaceFileKey } = await import(
'@/lib/uploads/contexts/workspace/workspace-file-manager'
)
customKey = generateWorkspaceFileKey(workspaceId, fileName)
} else if (context === 'execution') {
const workflowId = (data as { workflowId?: unknown }).workflowId
const executionId = (data as { executionId?: unknown }).executionId
Expand Down
7 changes: 5 additions & 2 deletions apps/sim/app/api/form/manage/[id]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -197,9 +197,12 @@ export const DELETE = withRouteHandler(
return createErrorResponse('Form not found or access denied', 404)
}

await db.delete(form).where(eq(form.id, id))
await db
.update(form)
.set({ archivedAt: new Date(), isActive: false, updatedAt: new Date() })
.where(eq(form.id, id))

logger.info(`Form ${id} deleted (soft delete)`)
logger.info(`Form ${id} soft deleted`)

recordAudit({
workspaceId: formWorkspaceId ?? null,
Expand Down
59 changes: 11 additions & 48 deletions apps/sim/app/api/tools/gmail/labels/route.ts
Original file line number Diff line number Diff line change
@@ -1,19 +1,15 @@
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 { gmailLabelsSelectorContract } 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'
export const dynamic = 'force-dynamic'
Expand All @@ -32,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 labels request rejected`)
return NextResponse.json({ error: 'User not authenticated' }, { status: 401 })
}

const parsed = await parseRequest(gmailLabelsSelectorContract, request, {})
if (!parsed.success) return parsed.response
const { credentialId, query } = parsed.data.query
Expand All @@ -50,52 +39,26 @@ 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
)
Comment thread
waleedlatif1 marked this conversation as resolved.
if (perm === null) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
}
const authz = await authorizeCredentialUse(request, {
credentialId,
requireWorkflowIdForInternal: false,
})
if (!authz.ok || !authz.credentialOwnerUserId || !authz.resolvedCredentialId) {
return NextResponse.json({ error: authz.error || 'Unauthorized' }, { status: 403 })
}

let accessToken: string | null = null

if (resolved.credentialType === 'service_account' && resolved.credentialId) {
if (authz.credentialType === 'service_account') {
accessToken = await getServiceAccountToken(
resolved.credentialId,
authz.resolvedCredentialId,
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,
credentialId,
authz.credentialOwnerUserId,
requestId,
getScopesForService('gmail')
)
Expand Down
48 changes: 10 additions & 38 deletions apps/sim/app/api/tools/onedrive/folders/route.ts
Original file line number Diff line number Diff line change
@@ -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 { onedriveFoldersQuerySchema } 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'
Expand All @@ -23,11 +20,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 = onedriveFoldersQuerySchema.safeParse({
credentialId: searchParams.get('credentialId') ?? '',
Expand All @@ -51,37 +43,17 @@ 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) {
return NextResponse.json({ error: 'Credential not found' }, { status: 404 })
const authz = await authorizeCredentialUse(request, {
credentialId,
requireWorkflowIdForInternal: false,
})
if (!authz.ok || !authz.credentialOwnerUserId || !authz.resolvedCredentialId) {
return NextResponse.json({ error: authz.error || 'Unauthorized' }, { status: 403 })
}

const accountRow = credentials[0]

const accessToken = await refreshAccessTokenIfNeeded(
resolved.accountId,
accountRow.userId,
credentialId,
authz.credentialOwnerUserId,
requestId
)
if (!accessToken) {
Expand Down
8 changes: 8 additions & 0 deletions apps/sim/app/api/tools/sftp/upload/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { generateRequestId } from '@/lib/core/utils/request'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { processFilesToUserFiles } from '@/lib/uploads/utils/file-utils'
import { downloadFileFromStorage } from '@/lib/uploads/utils/file-utils.server'
import { assertToolFileAccess } from '@/app/api/files/authorization'
import {
createSftpConnection,
getSftp,
Expand Down Expand Up @@ -95,6 +96,13 @@ export const POST = withRouteHandler(async (request: NextRequest) => {

for (const file of userFiles) {
try {
const denied = await assertToolFileAccess(
file.key,
authResult.userId,
requestId,
logger
)
if (denied) return denied
logger.info(
`[${requestId}] Downloading file for upload: ${file.name} (${file.size} bytes)`
)
Expand Down
3 changes: 3 additions & 0 deletions apps/sim/app/api/tools/sharepoint/upload/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { generateRequestId } from '@/lib/core/utils/request'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { processFilesToUserFiles } from '@/lib/uploads/utils/file-utils'
import { downloadFileFromStorage } from '@/lib/uploads/utils/file-utils.server'
import { assertToolFileAccess } from '@/app/api/files/authorization'
import type { MicrosoftGraphDriveItem } from '@/tools/onedrive/types'
import type { SharepointSkippedFile, SharepointUploadError } from '@/tools/sharepoint/types'

Expand Down Expand Up @@ -82,6 +83,8 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
const errors: SharepointUploadError[] = []

for (const userFile of userFiles) {
const denied = await assertToolFileAccess(userFile.key, authResult.userId, requestId, logger)
if (denied) return denied
logger.info(`[${requestId}] Uploading file: ${userFile.name}`)

const buffer = await downloadFileFromStorage(userFile, requestId, logger)
Expand Down
Loading
Loading