Skip to content

Commit 80c9a01

Browse files
authored
fix(security): harden file access controls, webhook auth, and input bounds (#4601)
* fix(security): harden file access controls, webhook auth, and input bounds * fix(security): extend file access checks to remaining tool routes * fix(logs): address PR review comments on time filter * fix(logs): set end-time milliseconds to 999 for datetime filter strings * fix(files): return 404 instead of 500 on file access denial in utility paths * remove tooltip from resource tabs
1 parent 4a9e248 commit 80c9a01

52 files changed

Lines changed: 517 additions & 277 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

apps/sim/app/api/files/authorization.ts

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,14 @@ import { isUuid } from '@/executor/constants'
1414

1515
const logger = createLogger('FileAuthorization')
1616

17+
/** Thrown by utility functions when file access is denied, so route handlers can return 404. */
18+
export class FileAccessDeniedError extends Error {
19+
constructor() {
20+
super('File not found')
21+
this.name = 'FileAccessDeniedError'
22+
}
23+
}
24+
1725
interface AuthorizationResult {
1826
granted: boolean
1927
reason: string
@@ -598,18 +606,14 @@ async function authorizeFileAccess(
598606
*/
599607
export async function assertToolFileAccess(
600608
key: unknown,
601-
userId: string | undefined,
609+
userId: string,
602610
requestId: string,
603611
routeLogger: ReturnType<typeof createLogger>
604612
): Promise<NextResponse | null> {
605613
if (typeof key !== 'string' || key.length === 0) {
606614
routeLogger.warn(`[${requestId}] File access check rejected: missing key`)
607615
return NextResponse.json({ success: false, error: 'File not found' }, { status: 404 })
608616
}
609-
if (!userId) {
610-
routeLogger.warn(`[${requestId}] File access check requires userId but none available`)
611-
return NextResponse.json({ success: false, error: 'File not found' }, { status: 404 })
612-
}
613617
const hasAccess = await verifyFileAccess(key, userId)
614618
if (!hasAccess) {
615619
routeLogger.warn(`[${requestId}] File access denied for user`, { userId, key })

apps/sim/app/api/tools/agiloft/attach/route.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
1010
import type { RawFileInput } from '@/lib/uploads/utils/file-schemas'
1111
import { processFilesToUserFiles } from '@/lib/uploads/utils/file-utils'
1212
import { downloadFileFromStorage } from '@/lib/uploads/utils/file-utils.server'
13+
import { assertToolFileAccess } from '@/app/api/files/authorization'
1314
import { agiloftLogin, agiloftLogout, buildAttachFileUrl } from '@/tools/agiloft/utils'
1415

1516
export const dynamic = 'force-dynamic'
@@ -22,7 +23,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
2223
try {
2324
const authResult = await checkInternalAuth(request, { requireWorkflowId: false })
2425

25-
if (!authResult.success) {
26+
if (!authResult.success || !authResult.userId) {
2627
logger.warn(`[${requestId}] Unauthorized Agiloft attach attempt: ${authResult.error}`)
2728
return NextResponse.json(
2829
{ success: false, error: authResult.error || 'Authentication required' },
@@ -66,6 +67,8 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
6667
`[${requestId}] Downloading file for Agiloft attach: ${userFile.name} (${userFile.size} bytes)`
6768
)
6869

70+
const denied = await assertToolFileAccess(userFile.key, authResult.userId, requestId, logger)
71+
if (denied) return denied
6972
const fileBuffer = await downloadFileFromStorage(userFile, requestId, logger)
7073
const resolvedFileName = data.fileName || userFile.name || 'attachment'
7174

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

Lines changed: 4 additions & 1 deletion
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, type RawFileInput } from '@/lib/uploads/utils/file-utils'
99
import { downloadFileFromStorage } from '@/lib/uploads/utils/file-utils.server'
10+
import { assertToolFileAccess } from '@/app/api/files/authorization'
1011

1112
export const dynamic = 'force-dynamic'
1213

@@ -18,7 +19,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
1819
try {
1920
const authResult = await checkInternalAuth(request, { requireWorkflowId: false })
2021

21-
if (!authResult.success) {
22+
if (!authResult.success || !authResult.userId) {
2223
logger.warn(`[${requestId}] Unauthorized Box upload attempt: ${authResult.error}`)
2324
return NextResponse.json(
2425
{ success: false, error: authResult.error || 'Authentication required' },
@@ -49,6 +50,8 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
4950
const userFile = userFiles[0]
5051
logger.info(`[${requestId}] Downloading file: ${userFile.name} (${userFile.size} bytes)`)
5152

53+
const denied = await assertToolFileAccess(userFile.key, authResult.userId, requestId, logger)
54+
if (denied) return denied
5255
fileBuffer = await downloadFileFromStorage(userFile, requestId, logger)
5356
fileName = validatedData.fileName || userFile.name
5457
} else if (validatedData.fileContent) {

apps/sim/app/api/tools/confluence/upload-attachment/route.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { validateAlphanumericId, validateJiraCloudId } from '@/lib/core/security
77
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
88
import { processSingleFileToUserFile, type RawFileInput } from '@/lib/uploads/utils/file-utils'
99
import { downloadFileFromStorage } from '@/lib/uploads/utils/file-utils.server'
10+
import { assertToolFileAccess } from '@/app/api/files/authorization'
1011
import { getConfluenceCloudId } from '@/tools/confluence/utils'
1112
import { parseAtlassianErrorMessage } from '@/tools/jira/utils'
1213

@@ -80,6 +81,14 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
8081
)
8182
}
8283

84+
const denied = await assertToolFileAccess(
85+
userFile.key,
86+
auth.userId,
87+
'confluence-upload',
88+
logger
89+
)
90+
if (denied) return denied
91+
8392
let fileBuffer: Buffer
8493
try {
8594
fileBuffer = await downloadFileFromStorage(userFile, 'confluence-upload', logger)

apps/sim/app/api/tools/discord/send-message/route.ts

Lines changed: 25 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { generateRequestId } from '@/lib/core/utils/request'
88
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
99
import { processFilesToUserFiles } from '@/lib/uploads/utils/file-utils'
1010
import { downloadFileFromStorage } from '@/lib/uploads/utils/file-utils.server'
11+
import { assertToolFileAccess } from '@/app/api/files/authorization'
1112

1213
export const dynamic = 'force-dynamic'
1314

@@ -19,7 +20,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
1920
try {
2021
const authResult = await checkInternalAuth(request, { requireWorkflowId: false })
2122

22-
if (!authResult.success) {
23+
if (!authResult.success || !authResult.userId) {
2324
logger.warn(`[${requestId}] Unauthorized Discord send attempt: ${authResult.error}`)
2425
return NextResponse.json(
2526
{
@@ -30,8 +31,9 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
3031
)
3132
}
3233

34+
const userId = authResult.userId
3335
logger.info(`[${requestId}] Authenticated Discord send request via ${authResult.authType}`, {
34-
userId: authResult.userId,
36+
userId,
3537
})
3638

3739
const parsed = await parseRequest(discordSendMessageContract, request, {})
@@ -134,17 +136,30 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
134136
}
135137
formData.append('payload_json', JSON.stringify(payload))
136138

137-
const downloadedFiles = await Promise.all(
138-
userFiles.map(async (userFile, i) => {
139-
logger.info(`[${requestId}] Downloading file ${i}: ${userFile.name}`)
140-
const buffer = await downloadFileFromStorage(userFile, requestId, logger)
141-
logger.info(`[${requestId}] Added file ${i}: ${userFile.name} (${buffer.length} bytes)`)
142-
return { userFile, buffer }
139+
const accessResults = await Promise.all(
140+
userFiles.map((file) => assertToolFileAccess(file.key, userId, requestId, logger))
141+
)
142+
const denied = accessResults.find((r) => r !== null)
143+
if (denied) return denied
144+
145+
const buffers = await Promise.all(
146+
userFiles.map(async (file, i) => {
147+
try {
148+
logger.info(`[${requestId}] Downloading file ${i}: ${file.name}`)
149+
return await downloadFileFromStorage(file, requestId, logger)
150+
} catch (error) {
151+
logger.error(`[${requestId}] Failed to download attachment ${file.name}:`, error)
152+
throw new Error(
153+
`Failed to download attachment "${file.name}": ${error instanceof Error ? error.message : 'Unknown error'}`
154+
)
155+
}
143156
})
144157
)
145158

146-
for (let i = 0; i < downloadedFiles.length; i++) {
147-
const { userFile, buffer } = downloadedFiles[i]
159+
for (let i = 0; i < userFiles.length; i++) {
160+
const userFile = userFiles[i]
161+
const buffer = buffers[i]
162+
logger.info(`[${requestId}] Added file ${i}: ${userFile.name} (${buffer.length} bytes)`)
148163
filesOutput.push({
149164
name: userFile.name,
150165
mimeType: userFile.type || 'application/octet-stream',

apps/sim/app/api/tools/docusign/route.ts

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
77
import { FileInputSchema } from '@/lib/uploads/utils/file-schemas'
88
import { processFilesToUserFiles, type RawFileInput } from '@/lib/uploads/utils/file-utils'
99
import { downloadFileFromStorage } from '@/lib/uploads/utils/file-utils.server'
10+
import { assertToolFileAccess } from '@/app/api/files/authorization'
1011

1112
const logger = createLogger('DocuSignAPI')
1213

@@ -54,7 +55,7 @@ async function resolveAccount(accessToken: string): Promise<DocuSignAccountInfo>
5455

5556
export const POST = withRouteHandler(async (request: NextRequest) => {
5657
const authResult = await checkInternalAuth(request, { requireWorkflowId: false })
57-
if (!authResult.success) {
58+
if (!authResult.success || !authResult.userId) {
5859
return NextResponse.json({ success: false, error: 'Unauthorized' }, { status: 401 })
5960
}
6061

@@ -84,7 +85,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
8485

8586
switch (operation) {
8687
case 'send_envelope':
87-
return await handleSendEnvelope(apiBase, headers, params)
88+
return await handleSendEnvelope(apiBase, headers, params, authResult.userId)
8889
case 'create_from_template':
8990
return await handleCreateFromTemplate(apiBase, headers, params)
9091
case 'get_envelope':
@@ -115,7 +116,8 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
115116
async function handleSendEnvelope(
116117
apiBase: string,
117118
headers: Record<string, string>,
118-
params: Record<string, unknown>
119+
params: Record<string, unknown>,
120+
userId: string
119121
) {
120122
const { signerEmail, signerName, emailSubject, emailBody, ccEmail, ccName, file, status } = params
121123

@@ -135,6 +137,8 @@ async function handleSendEnvelope(
135137
const userFiles = processFilesToUserFiles([parsed as RawFileInput], 'docusign-send', logger)
136138
if (userFiles.length > 0) {
137139
const userFile = userFiles[0]
140+
const denied = await assertToolFileAccess(userFile.key, userId, 'docusign-send', logger)
141+
if (denied) return denied
138142
const buffer = await downloadFileFromStorage(userFile, 'docusign-send', logger)
139143
documentBase64 = buffer.toString('base64')
140144
documentName = userFile.name

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

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { httpHeaderSafeJson } from '@/lib/core/utils/validation'
88
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
99
import { processFilesToUserFiles, type RawFileInput } from '@/lib/uploads/utils/file-utils'
1010
import { downloadFileFromStorage } from '@/lib/uploads/utils/file-utils.server'
11+
import { assertToolFileAccess } from '@/app/api/files/authorization'
1112

1213
export const dynamic = 'force-dynamic'
1314

@@ -19,7 +20,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
1920
try {
2021
const authResult = await checkInternalAuth(request, { requireWorkflowId: false })
2122

22-
if (!authResult.success) {
23+
if (!authResult.success || !authResult.userId) {
2324
logger.warn(`[${requestId}] Unauthorized Dropbox upload attempt: ${authResult.error}`)
2425
return NextResponse.json(
2526
{ success: false, error: authResult.error || 'Authentication required' },
@@ -52,6 +53,8 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
5253
const userFile = userFiles[0]
5354
logger.info(`[${requestId}] Downloading file: ${userFile.name} (${userFile.size} bytes)`)
5455

56+
const denied = await assertToolFileAccess(userFile.key, authResult.userId, requestId, logger)
57+
if (denied) return denied
5558
fileBuffer = await downloadFileFromStorage(userFile, requestId, logger)
5659
fileName = userFile.name
5760
} else if (validatedData.fileContent) {

apps/sim/app/api/tools/firecrawl/parse/route.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { generateRequestId } from '@/lib/core/utils/request'
88
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
99
import { processFilesToUserFiles } from '@/lib/uploads/utils/file-utils'
1010
import { downloadFileFromStorage } from '@/lib/uploads/utils/file-utils.server'
11+
import { assertToolFileAccess } from '@/app/api/files/authorization'
1112

1213
export const dynamic = 'force-dynamic'
1314

@@ -43,6 +44,9 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
4344
size: userFile.size,
4445
})
4546

47+
const denied = await assertToolFileAccess(userFile.key, authResult.userId, requestId, logger)
48+
if (denied) return denied
49+
4650
const buffer = await downloadFileFromStorage(userFile, requestId, logger)
4751

4852
const formData = new FormData()

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

Lines changed: 18 additions & 11 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 { assertToolFileAccess } from '@/app/api/files/authorization'
1011
import {
1112
base64UrlEncode,
1213
buildMimeMessage,
@@ -26,7 +27,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
2627
try {
2728
const authResult = await checkInternalAuth(request, { requireWorkflowId: false })
2829

29-
if (!authResult.success) {
30+
if (!authResult.success || !authResult.userId) {
3031
logger.warn(`[${requestId}] Unauthorized Gmail draft attempt: ${authResult.error}`)
3132
return NextResponse.json(
3233
{
@@ -37,8 +38,9 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
3738
)
3839
}
3940

41+
const userId = authResult.userId
4042
logger.info(`[${requestId}] Authenticated Gmail draft request via ${authResult.authType}`, {
41-
userId: authResult.userId,
43+
userId,
4244
})
4345

4446
const parsed = await parseRequest(gmailDraftContract, request, {})
@@ -85,20 +87,19 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
8587
)
8688
}
8789

88-
const attachmentBuffers = await Promise.all(
90+
const accessResults = await Promise.all(
91+
attachments.map((file) => assertToolFileAccess(file.key, userId, requestId, logger))
92+
)
93+
const denied = accessResults.find((r) => r !== null)
94+
if (denied) return denied
95+
96+
const buffers = await Promise.all(
8997
attachments.map(async (file) => {
9098
try {
9199
logger.info(
92100
`[${requestId}] Downloading attachment: ${file.name} (${file.size} bytes)`
93101
)
94-
95-
const buffer = await downloadFileFromStorage(file, requestId, logger)
96-
97-
return {
98-
filename: file.name,
99-
mimeType: file.type || 'application/octet-stream',
100-
content: buffer,
101-
}
102+
return await downloadFileFromStorage(file, requestId, logger)
102103
} catch (error) {
103104
logger.error(`[${requestId}] Failed to download attachment ${file.name}:`, error)
104105
throw new Error(
@@ -108,6 +109,12 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
108109
})
109110
)
110111

112+
const attachmentBuffers = attachments.map((file, i) => ({
113+
filename: file.name,
114+
mimeType: file.type || 'application/octet-stream',
115+
content: buffers[i],
116+
}))
117+
111118
const mimeMessage = buildMimeMessage({
112119
to: validatedData.to,
113120
cc: validatedData.cc ?? undefined,

0 commit comments

Comments
 (0)