Skip to content

Commit 398dc65

Browse files
committed
fix(security): harden HIGH deepsec findings across multiple attack surfaces
- Supabase tools (get_row, delete, update): validate table name with strict identifier regex and encodeURIComponent to prevent LLM-controlled path traversal to admin endpoints; add missing empty-filter guard to update matching the delete.ts pattern - SFTP/SMTP/SharePoint upload routes: add verifyFileAccess ownership check before downloadFileFromStorage, matching the WordPress reference pattern; rejects files the requesting user does not own with 404 - Gmail labels, OneDrive folders, Wealthbox items (×2): replace bare resolveOAuthAccountId + workspace-only membership check with authorizeCredentialUse which enforces credentialMember table; use credentialOwnerUserId for token refresh instead of bare accountRow.userId - A2A utils: thread pre-resolved IP from validateUrlWithDNS into A2A SDK via pinnedFetch (secureFetchWithPinnedIP) for JsonRpcTransportFactory, RestTransportFactory, and DefaultAgentCardResolver, closing the TOCTOU DNS rebinding window - SSH utils: cap stdout/stderr accumulation at 16 MB with truncation marker to prevent OOM from unbounded command output - Form DELETE route: replace db.delete() with db.update({archivedAt}) for true soft delete matching the schema's archivedAt column - Workflow admin import: fix Array.isArray() guard that silently dropped all variables (export format is Record, not Array) - Multipart upload: apply checkStorageQuota and MAX_WORKSPACE_FILE_SIZE to mothership context, closing the quota bypass for workspace-scoped storage
1 parent d1eb79e commit 398dc65

19 files changed

Lines changed: 244 additions & 189 deletions

File tree

apps/sim/app/api/auth/oauth/wealthbox/items/route.ts

Lines changed: 10 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,11 @@
1-
import { db } from '@sim/db'
2-
import { account } from '@sim/db/schema'
31
import { createLogger } from '@sim/logger'
4-
import { eq } from 'drizzle-orm'
52
import { type NextRequest, NextResponse } from 'next/server'
63
import { wealthboxOAuthItemsContract } from '@/lib/api/contracts/selectors/wealthbox'
74
import { parseRequest } from '@/lib/api/server'
8-
import { getSession } from '@/lib/auth'
5+
import { authorizeCredentialUse } from '@/lib/auth/credential-access'
96
import { generateRequestId } from '@/lib/core/utils/request'
107
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
11-
import { refreshAccessTokenIfNeeded, resolveOAuthAccountId } from '@/app/api/auth/oauth/utils'
8+
import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils'
129

1310
export const dynamic = 'force-dynamic'
1411

@@ -30,51 +27,22 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
3027
const requestId = generateRequestId()
3128

3229
try {
33-
const session = await getSession()
34-
35-
if (!session?.user?.id) {
36-
logger.warn(`[${requestId}] Unauthenticated request rejected`)
37-
return NextResponse.json({ error: 'User not authenticated' }, { status: 401 })
38-
}
39-
4030
const parsed = await parseRequest(wealthboxOAuthItemsContract, request, {})
4131
if (!parsed.success) return parsed.response
4232
const { credentialId, type } = parsed.data.query
4333
const query = parsed.data.query.query ?? ''
4434

45-
const resolved = await resolveOAuthAccountId(credentialId)
46-
if (!resolved) {
47-
return NextResponse.json({ error: 'Credential not found' }, { status: 404 })
48-
}
49-
50-
if (resolved.workspaceId) {
51-
const { getUserEntityPermissions } = await import('@/lib/workspaces/permissions/utils')
52-
const perm = await getUserEntityPermissions(
53-
session.user.id,
54-
'workspace',
55-
resolved.workspaceId
56-
)
57-
if (perm === null) {
58-
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
59-
}
60-
}
61-
62-
const credentials = await db
63-
.select()
64-
.from(account)
65-
.where(eq(account.id, resolved.accountId))
66-
.limit(1)
67-
68-
if (!credentials.length) {
69-
logger.warn(`[${requestId}] Credential not found`, { credentialId })
70-
return NextResponse.json({ error: 'Credential not found' }, { status: 404 })
35+
const authz = await authorizeCredentialUse(request as any, {
36+
credentialId,
37+
requireWorkflowIdForInternal: false,
38+
})
39+
if (!authz.ok || !authz.credentialOwnerUserId) {
40+
return NextResponse.json({ error: authz.error || 'Unauthorized' }, { status: 403 })
7141
}
7242

73-
const accountRow = credentials[0]
74-
7543
const accessToken = await refreshAccessTokenIfNeeded(
76-
resolved.accountId,
77-
accountRow.userId,
44+
credentialId,
45+
authz.credentialOwnerUserId,
7846
requestId
7947
)
8048

apps/sim/app/api/files/multipart/route.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -159,10 +159,27 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
159159
)
160160
}
161161
} else if (context === 'mothership') {
162+
const { MAX_WORKSPACE_FILE_SIZE } = await import('@/lib/uploads/shared/types')
163+
if (typeof fileSize === 'number' && fileSize > MAX_WORKSPACE_FILE_SIZE) {
164+
return NextResponse.json(
165+
{ error: `File size exceeds maximum of ${MAX_WORKSPACE_FILE_SIZE} bytes` },
166+
{ status: 413 }
167+
)
168+
}
169+
162170
const { generateWorkspaceFileKey } = await import(
163171
'@/lib/uploads/contexts/workspace/workspace-file-manager'
164172
)
165173
customKey = generateWorkspaceFileKey(workspaceId, fileName)
174+
175+
const { checkStorageQuota } = await import('@/lib/billing/storage')
176+
const quotaCheck = await checkStorageQuota(userId, fileSize)
177+
if (!quotaCheck.allowed) {
178+
return NextResponse.json(
179+
{ error: quotaCheck.error || 'Storage limit exceeded' },
180+
{ status: 413 }
181+
)
182+
}
166183
} else if (context === 'execution') {
167184
const workflowId = (data as { workflowId?: unknown }).workflowId
168185
const executionId = (data as { executionId?: unknown }).executionId

apps/sim/app/api/form/manage/[id]/route.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -197,9 +197,12 @@ export const DELETE = withRouteHandler(
197197
return createErrorResponse('Form not found or access denied', 404)
198198
}
199199

200-
await db.delete(form).where(eq(form.id, id))
200+
await db
201+
.update(form)
202+
.set({ archivedAt: new Date(), updatedAt: new Date() })
203+
.where(eq(form.id, id))
201204

202-
logger.info(`Form ${id} deleted (soft delete)`)
205+
logger.info(`Form ${id} soft deleted`)
203206

204207
recordAudit({
205208
workspaceId: formWorkspaceId ?? null,

apps/sim/app/api/tools/gmail/labels/route.ts

Lines changed: 11 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,8 @@
1-
import { db } from '@sim/db'
2-
import { account } from '@sim/db/schema'
31
import { createLogger } from '@sim/logger'
4-
import { eq } from 'drizzle-orm'
52
import { type NextRequest, NextResponse } from 'next/server'
63
import { gmailLabelsSelectorContract } from '@/lib/api/contracts/selectors/google'
74
import { parseRequest } from '@/lib/api/server'
8-
import { getSession } from '@/lib/auth'
5+
import { authorizeCredentialUse } from '@/lib/auth/credential-access'
96
import { validateAlphanumericId } from '@/lib/core/security/input-validation'
107
import { generateRequestId } from '@/lib/core/utils/request'
118
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
@@ -32,13 +29,6 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
3229
const requestId = generateRequestId()
3330

3431
try {
35-
const session = await getSession()
36-
37-
if (!session?.user?.id) {
38-
logger.warn(`[${requestId}] Unauthenticated labels request rejected`)
39-
return NextResponse.json({ error: 'User not authenticated' }, { status: 401 })
40-
}
41-
4232
const parsed = await parseRequest(gmailLabelsSelectorContract, request, {})
4333
if (!parsed.success) return parsed.response
4434
const { credentialId, query } = parsed.data.query
@@ -50,23 +40,19 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
5040
return NextResponse.json({ error: credentialIdValidation.error }, { status: 400 })
5141
}
5242

43+
const authz = await authorizeCredentialUse(request as any, {
44+
credentialId,
45+
requireWorkflowIdForInternal: false,
46+
})
47+
if (!authz.ok || !authz.credentialOwnerUserId) {
48+
return NextResponse.json({ error: authz.error || 'Unauthorized' }, { status: 403 })
49+
}
50+
5351
const resolved = await resolveOAuthAccountId(credentialId)
5452
if (!resolved) {
5553
return NextResponse.json({ error: 'Credential not found' }, { status: 404 })
5654
}
5755

58-
if (resolved.workspaceId) {
59-
const { getUserEntityPermissions } = await import('@/lib/workspaces/permissions/utils')
60-
const perm = await getUserEntityPermissions(
61-
session.user.id,
62-
'workspace',
63-
resolved.workspaceId
64-
)
65-
if (perm === null) {
66-
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
67-
}
68-
}
69-
7056
let accessToken: string | null = null
7157

7258
if (resolved.credentialType === 'service_account' && resolved.credentialId) {
@@ -76,26 +62,9 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
7662
impersonateEmail
7763
)
7864
} else {
79-
const credentials = await db
80-
.select()
81-
.from(account)
82-
.where(eq(account.id, resolved.accountId))
83-
.limit(1)
84-
85-
if (!credentials.length) {
86-
logger.warn(`[${requestId}] Credential not found`)
87-
return NextResponse.json({ error: 'Credential not found' }, { status: 404 })
88-
}
89-
90-
const accountRow = credentials[0]
91-
92-
logger.info(
93-
`[${requestId}] Using credential: ${accountRow.id}, provider: ${accountRow.providerId}`
94-
)
95-
9665
accessToken = await refreshAccessTokenIfNeeded(
97-
resolved.accountId,
98-
accountRow.userId,
66+
credentialId,
67+
authz.credentialOwnerUserId,
9968
requestId,
10069
getScopesForService('gmail')
10170
)

apps/sim/app/api/tools/onedrive/folders/route.ts

Lines changed: 10 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,12 @@
1-
import { db } from '@sim/db'
2-
import { account } from '@sim/db/schema'
31
import { createLogger } from '@sim/logger'
42
import { generateId } from '@sim/utils/id'
5-
import { eq } from 'drizzle-orm'
63
import { type NextRequest, NextResponse } from 'next/server'
74
import { onedriveFoldersQuerySchema } from '@/lib/api/contracts/selectors/microsoft'
85
import { getValidationErrorMessage } from '@/lib/api/server'
9-
import { getSession } from '@/lib/auth'
6+
import { authorizeCredentialUse } from '@/lib/auth/credential-access'
107
import { validateMicrosoftGraphId } from '@/lib/core/security/input-validation'
118
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
12-
import { refreshAccessTokenIfNeeded, resolveOAuthAccountId } from '@/app/api/auth/oauth/utils'
9+
import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils'
1310
import type { MicrosoftGraphDriveItem } from '@/tools/onedrive/types'
1411

1512
export const dynamic = 'force-dynamic'
@@ -23,11 +20,6 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
2320
const requestId = generateId().slice(0, 8)
2421

2522
try {
26-
const session = await getSession()
27-
if (!session?.user?.id) {
28-
return NextResponse.json({ error: 'User not authenticated' }, { status: 401 })
29-
}
30-
3123
const { searchParams } = new URL(request.url)
3224
const validation = onedriveFoldersQuerySchema.safeParse({
3325
credentialId: searchParams.get('credentialId') ?? '',
@@ -51,37 +43,17 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
5143
return NextResponse.json({ error: credentialIdValidation.error }, { status: 400 })
5244
}
5345

54-
const resolved = await resolveOAuthAccountId(credentialId)
55-
if (!resolved) {
56-
return NextResponse.json({ error: 'Credential not found' }, { status: 404 })
57-
}
58-
59-
if (resolved.workspaceId) {
60-
const { getUserEntityPermissions } = await import('@/lib/workspaces/permissions/utils')
61-
const perm = await getUserEntityPermissions(
62-
session.user.id,
63-
'workspace',
64-
resolved.workspaceId
65-
)
66-
if (perm === null) {
67-
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
68-
}
69-
}
70-
71-
const credentials = await db
72-
.select()
73-
.from(account)
74-
.where(eq(account.id, resolved.accountId))
75-
.limit(1)
76-
if (!credentials.length) {
77-
return NextResponse.json({ error: 'Credential not found' }, { status: 404 })
46+
const authz = await authorizeCredentialUse(request as any, {
47+
credentialId,
48+
requireWorkflowIdForInternal: false,
49+
})
50+
if (!authz.ok || !authz.credentialOwnerUserId) {
51+
return NextResponse.json({ error: authz.error || 'Unauthorized' }, { status: 403 })
7852
}
7953

80-
const accountRow = credentials[0]
81-
8254
const accessToken = await refreshAccessTokenIfNeeded(
83-
resolved.accountId,
84-
accountRow.userId,
55+
credentialId,
56+
authz.credentialOwnerUserId,
8557
requestId
8658
)
8759
if (!accessToken) {

apps/sim/app/api/tools/sftp/upload/route.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { generateRequestId } from '@/lib/core/utils/request'
77
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
88
import { processFilesToUserFiles } from '@/lib/uploads/utils/file-utils'
99
import { downloadFileFromStorage } from '@/lib/uploads/utils/file-utils.server'
10+
import { verifyFileAccess } from '@/app/api/files/authorization'
1011
import {
1112
createSftpConnection,
1213
getSftp,
@@ -95,6 +96,22 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
9596

9697
for (const file of userFiles) {
9798
try {
99+
if (typeof file.key !== 'string' || file.key.length === 0) {
100+
logger.warn(`[${requestId}] File access check rejected: missing key`)
101+
return NextResponse.json({ success: false, error: 'File not found' }, { status: 404 })
102+
}
103+
if (!authResult.userId) {
104+
logger.warn(`[${requestId}] File access check requires userId but none available`)
105+
return NextResponse.json({ success: false, error: 'File not found' }, { status: 404 })
106+
}
107+
const hasAccess = await verifyFileAccess(file.key, authResult.userId)
108+
if (!hasAccess) {
109+
logger.warn(`[${requestId}] File access denied for user`, {
110+
userId: authResult.userId,
111+
key: file.key,
112+
})
113+
return NextResponse.json({ success: false, error: 'File not found' }, { status: 404 })
114+
}
98115
logger.info(
99116
`[${requestId}] Downloading file for upload: ${file.name} (${file.size} bytes)`
100117
)

apps/sim/app/api/tools/sharepoint/upload/route.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { generateRequestId } from '@/lib/core/utils/request'
99
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
1010
import { processFilesToUserFiles } from '@/lib/uploads/utils/file-utils'
1111
import { downloadFileFromStorage } from '@/lib/uploads/utils/file-utils.server'
12+
import { verifyFileAccess } from '@/app/api/files/authorization'
1213
import type { MicrosoftGraphDriveItem } from '@/tools/onedrive/types'
1314
import type { SharepointSkippedFile, SharepointUploadError } from '@/tools/sharepoint/types'
1415

@@ -82,6 +83,22 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
8283
const errors: SharepointUploadError[] = []
8384

8485
for (const userFile of userFiles) {
86+
if (typeof userFile.key !== 'string' || userFile.key.length === 0) {
87+
logger.warn(`[${requestId}] File access check rejected: missing key`)
88+
return NextResponse.json({ success: false, error: 'File not found' }, { status: 404 })
89+
}
90+
if (!authResult.userId) {
91+
logger.warn(`[${requestId}] File access check requires userId but none available`)
92+
return NextResponse.json({ success: false, error: 'File not found' }, { status: 404 })
93+
}
94+
const hasAccess = await verifyFileAccess(userFile.key, authResult.userId)
95+
if (!hasAccess) {
96+
logger.warn(`[${requestId}] File access denied for user`, {
97+
userId: authResult.userId,
98+
key: userFile.key,
99+
})
100+
return NextResponse.json({ success: false, error: 'File not found' }, { status: 404 })
101+
}
85102
logger.info(`[${requestId}] Uploading file: ${userFile.name}`)
86103

87104
const buffer = await downloadFileFromStorage(userFile, requestId, logger)

apps/sim/app/api/tools/smtp/send/route.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import { generateRequestId } from '@/lib/core/utils/request'
1010
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
1111
import { processFilesToUserFiles } from '@/lib/uploads/utils/file-utils'
1212
import { downloadFileFromStorage } from '@/lib/uploads/utils/file-utils.server'
13+
import { verifyFileAccess } from '@/app/api/files/authorization'
1314

1415
export const dynamic = 'force-dynamic'
1516

@@ -122,6 +123,22 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
122123
const attachmentBuffers = await Promise.all(
123124
attachments.map(async (file) => {
124125
try {
126+
if (typeof file.key !== 'string' || file.key.length === 0) {
127+
logger.warn(`[${requestId}] File access check rejected: missing key`)
128+
throw new Error('File not found')
129+
}
130+
if (!authResult.userId) {
131+
logger.warn(`[${requestId}] File access check requires userId but none available`)
132+
throw new Error('File not found')
133+
}
134+
const hasAccess = await verifyFileAccess(file.key, authResult.userId)
135+
if (!hasAccess) {
136+
logger.warn(`[${requestId}] File access denied for user`, {
137+
userId: authResult.userId,
138+
key: file.key,
139+
})
140+
throw new Error('File not found')
141+
}
125142
logger.info(
126143
`[${requestId}] Downloading attachment: ${file.name} (${file.size} bytes)`
127144
)

0 commit comments

Comments
 (0)