Skip to content

Commit 56d6a45

Browse files
committed
fix(security): xlsx CVE bump and bundled security hardening
1 parent 690b7ab commit 56d6a45

20 files changed

Lines changed: 319 additions & 124 deletions

File tree

apps/sim/app/api/auth/trello/authorize/route.ts

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { createLogger } from '@sim/logger'
2+
import { generateShortId } from '@sim/utils/id'
23
import { type NextRequest, NextResponse } from 'next/server'
34
import { authorizeTrelloContract } from '@/lib/api/contracts/oauth-connections'
45
import { parseRequest } from '@/lib/api/server'
@@ -12,6 +13,10 @@ const logger = createLogger('TrelloAuthorize')
1213

1314
export const dynamic = 'force-dynamic'
1415

16+
const TRELLO_STATE_COOKIE = 'trello_oauth_state'
17+
const TRELLO_STATE_COOKIE_PATH = '/api/auth/trello'
18+
const TRELLO_STATE_COOKIE_MAX_AGE_SECONDS = 60 * 10
19+
1520
export const GET = withRouteHandler(async (request: NextRequest) => {
1621
try {
1722
const session = await getSession()
@@ -30,7 +35,9 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
3035
}
3136

3237
const baseUrl = getBaseUrl()
33-
const returnUrl = `${baseUrl}/api/auth/trello/callback`
38+
const state = generateShortId(32)
39+
const returnUrl = new URL('/api/auth/trello/callback', baseUrl)
40+
returnUrl.searchParams.set('state', state)
3441
const scope = getCanonicalScopesForProvider('trello').join(',')
3542

3643
const authUrl = new URL('https://trello.com/1/authorize')
@@ -40,9 +47,17 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
4047
authUrl.searchParams.set('callback_method', 'fragment')
4148
authUrl.searchParams.set('response_type', 'token')
4249
authUrl.searchParams.set('scope', scope)
43-
authUrl.searchParams.set('return_url', returnUrl)
50+
authUrl.searchParams.set('return_url', returnUrl.toString())
4451

45-
return NextResponse.redirect(authUrl.toString())
52+
const response = NextResponse.redirect(authUrl.toString())
53+
response.cookies.set(TRELLO_STATE_COOKIE, state, {
54+
httpOnly: true,
55+
secure: process.env.NODE_ENV === 'production',
56+
sameSite: 'lax',
57+
maxAge: TRELLO_STATE_COOKIE_MAX_AGE_SECONDS,
58+
path: TRELLO_STATE_COOKIE_PATH,
59+
})
60+
return response
4661
} catch (error) {
4762
logger.error('Error initiating Trello authorization:', error)
4863
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })

apps/sim/app/api/auth/trello/callback/route.ts

Lines changed: 39 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,54 @@
1+
import { createLogger } from '@sim/logger'
12
import { type NextRequest, NextResponse } from 'next/server'
23
import { trelloCallbackContract } from '@/lib/api/contracts/oauth-connections'
34
import { parseRequest } from '@/lib/api/server'
45
import { getBaseUrl } from '@/lib/core/utils/urls'
56
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
67

8+
const logger = createLogger('TrelloCallback')
9+
710
export const dynamic = 'force-dynamic'
811

12+
const TRELLO_STATE_COOKIE = 'trello_oauth_state'
13+
14+
function escapeForJsString(value: string): string {
15+
return value.replace(/[\\'"<>&\r\n\u2028\u2029]/g, (ch) => {
16+
return `\\u${ch.charCodeAt(0).toString(16).padStart(4, '0')}`
17+
})
18+
}
19+
20+
function renderErrorPage(baseUrl: string, redirectQuery: string) {
21+
return new NextResponse(
22+
`<!DOCTYPE html><html><head><meta charset="utf-8"><title>Trello connection failed</title></head><body><script>window.location.href=${JSON.stringify(`${baseUrl}/workspace?${redirectQuery}`)};</script><p>Trello connection failed. Redirecting...</p></body></html>`,
23+
{
24+
status: 400,
25+
headers: {
26+
'Content-Type': 'text/html; charset=utf-8',
27+
'Cache-Control': 'no-store, no-cache, must-revalidate',
28+
},
29+
}
30+
)
31+
}
32+
933
export const GET = withRouteHandler(async (request: NextRequest) => {
1034
const parsed = await parseRequest(trelloCallbackContract, request, {})
1135
if (!parsed.success) return parsed.response
1236

1337
const baseUrl = getBaseUrl()
38+
const queryState = parsed.data.query.state
39+
const cookieState = request.cookies.get(TRELLO_STATE_COOKIE)?.value
40+
41+
if (!queryState || !cookieState || queryState !== cookieState) {
42+
logger.warn('Trello callback rejected: state mismatch or missing state', {
43+
hasQueryState: Boolean(queryState),
44+
hasCookieState: Boolean(cookieState),
45+
})
46+
const response = renderErrorPage(baseUrl, 'error=trello_state_mismatch')
47+
response.cookies.delete({ name: TRELLO_STATE_COOKIE, path: '/api/auth/trello' })
48+
return response
49+
}
50+
51+
const safeState = escapeForJsString(queryState)
1452

1553
return new NextResponse(
1654
`<!DOCTYPE html>
@@ -97,7 +135,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
97135
method: 'POST',
98136
headers: { 'Content-Type': 'application/json' },
99137
credentials: 'include',
100-
body: JSON.stringify({ token: token })
138+
body: JSON.stringify({ token: token, state: '${safeState}' })
101139
})
102140
.then(response => response.json())
103141
.then(data => {

apps/sim/app/api/auth/trello/store/route.ts

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,14 @@ const logger = createLogger('TrelloStore')
1616

1717
export const dynamic = 'force-dynamic'
1818

19+
const TRELLO_STATE_COOKIE = 'trello_oauth_state'
20+
const TRELLO_STATE_COOKIE_PATH = '/api/auth/trello'
21+
22+
function clearStateCookie(response: NextResponse) {
23+
response.cookies.delete({ name: TRELLO_STATE_COOKIE, path: TRELLO_STATE_COOKIE_PATH })
24+
return response
25+
}
26+
1927
export const POST = withRouteHandler(async (request: NextRequest) => {
2028
try {
2129
const session = await getSession()
@@ -26,7 +34,21 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
2634

2735
const parsed = await parseRequest(storeTrelloTokenContract, request, {})
2836
if (!parsed.success) return parsed.response
29-
const { token } = parsed.data.body
37+
const { token, state } = parsed.data.body
38+
39+
const cookieState = request.cookies.get(TRELLO_STATE_COOKIE)?.value
40+
if (!cookieState || cookieState !== state) {
41+
logger.warn('Trello store rejected: state mismatch', {
42+
hasCookieState: Boolean(cookieState),
43+
userId: session.user.id,
44+
})
45+
return clearStateCookie(
46+
NextResponse.json(
47+
{ success: false, error: 'Invalid or expired authorization state' },
48+
{ status: 400 }
49+
)
50+
)
51+
}
3052

3153
const apiKey = env.TRELLO_API_KEY
3254
if (!apiKey) {
@@ -123,7 +145,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
123145
}
124146
}
125147

126-
return NextResponse.json({ success: true })
148+
return clearStateCookie(NextResponse.json({ success: true }))
127149
} catch (error) {
128150
logger.error('Error storing Trello token:', error)
129151
return NextResponse.json({ success: false, error: 'Internal server error' }, { status: 500 })

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

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,10 @@ export const PATCH = withRouteHandler(
110110
outputConfigs,
111111
} = validatedData
112112

113+
if (workflowId && workflowId !== existingChat[0].workflowId) {
114+
return createErrorResponse('Changing the workflow of a chat deployment is not allowed', 400)
115+
}
116+
113117
if (identifier && identifier !== existingChat[0].identifier) {
114118
const existingIdentifier = await db
115119
.select()
@@ -156,7 +160,6 @@ export const PATCH = withRouteHandler(
156160
updatedAt: new Date(),
157161
}
158162

159-
if (workflowId) updateData.workflowId = workflowId
160163
if (identifier) updateData.identifier = identifier
161164
if (title) updateData.title = title
162165
if (description !== undefined) updateData.description = description

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

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,8 @@ import { type NextRequest, NextResponse } from 'next/server'
44
import { agiloftAttachContract } from '@/lib/api/contracts/tools/agiloft'
55
import { getValidationErrorMessage, parseRequest } from '@/lib/api/server'
66
import { checkInternalAuth } from '@/lib/auth/hybrid'
7-
import { validateUrlWithDNS } from '@/lib/core/security/input-validation.server'
7+
import { validateAgiloftInstanceUrl } from '@/lib/core/security/input-validation'
8+
import { secureFetchWithPinnedIP } from '@/lib/core/security/input-validation.server'
89
import { generateRequestId } from '@/lib/core/utils/request'
910
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
1011
import type { RawFileInput } from '@/lib/uploads/utils/file-schemas'
@@ -69,26 +70,26 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
6970
const fileBuffer = await downloadFileFromStorage(userFile, requestId, logger)
7071
const resolvedFileName = data.fileName || userFile.name || 'attachment'
7172

72-
const urlValidation = await validateUrlWithDNS(data.instanceUrl, 'instanceUrl')
73-
if (!urlValidation.isValid) {
73+
const surfaceCheck = validateAgiloftInstanceUrl(data.instanceUrl)
74+
if (!surfaceCheck.isValid) {
7475
logger.warn(`[${requestId}] SSRF attempt blocked for Agiloft instance URL`, {
7576
instanceUrl: data.instanceUrl,
7677
})
7778
return NextResponse.json(
78-
{ success: false, error: urlValidation.error || 'Invalid instance URL' },
79+
{ success: false, error: surfaceCheck.error || 'Invalid instance URL' },
7980
{ status: 400 }
8081
)
8182
}
8283

83-
const token = await agiloftLogin(data)
84+
const { token, resolvedIP } = await agiloftLogin(data)
8485
const base = data.instanceUrl.replace(/\/$/, '')
8586

8687
try {
8788
const url = buildAttachFileUrl(base, data, resolvedFileName)
8889

8990
logger.info(`[${requestId}] Uploading file to Agiloft: ${resolvedFileName}`)
9091

91-
const agiloftResponse = await fetch(url, {
92+
const agiloftResponse = await secureFetchWithPinnedIP(url, resolvedIP, {
9293
method: 'PUT',
9394
headers: {
9495
'Content-Type': 'application/octet-stream',
@@ -132,7 +133,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
132133
},
133134
})
134135
} finally {
135-
await agiloftLogout(data.instanceUrl, data.knowledgeBase, token)
136+
await agiloftLogout(data.instanceUrl, data.knowledgeBase, token, resolvedIP)
136137
}
137138
} catch (error) {
138139
logger.error(`[${requestId}] Error attaching file to Agiloft:`, error)

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

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,8 @@ import { type NextRequest, NextResponse } from 'next/server'
44
import { agiloftRetrieveContract } from '@/lib/api/contracts/tools/agiloft'
55
import { getValidationErrorMessage, parseRequest } from '@/lib/api/server'
66
import { checkInternalAuth } from '@/lib/auth/hybrid'
7-
import { validateUrlWithDNS } from '@/lib/core/security/input-validation.server'
7+
import { validateAgiloftInstanceUrl } from '@/lib/core/security/input-validation'
8+
import { secureFetchWithPinnedIP } from '@/lib/core/security/input-validation.server'
89
import { generateRequestId } from '@/lib/core/utils/request'
910
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
1011
import { agiloftLogin, agiloftLogout, buildRetrieveAttachmentUrl } from '@/tools/agiloft/utils'
@@ -48,18 +49,18 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
4849
if (!parsed.success) return parsed.response
4950
const data = parsed.data.body
5051

51-
const urlValidation = await validateUrlWithDNS(data.instanceUrl, 'instanceUrl')
52-
if (!urlValidation.isValid) {
52+
const surfaceCheck = validateAgiloftInstanceUrl(data.instanceUrl)
53+
if (!surfaceCheck.isValid) {
5354
logger.warn(`[${requestId}] SSRF attempt blocked for Agiloft instance URL`, {
5455
instanceUrl: data.instanceUrl,
5556
})
5657
return NextResponse.json(
57-
{ success: false, error: urlValidation.error || 'Invalid instance URL' },
58+
{ success: false, error: surfaceCheck.error || 'Invalid instance URL' },
5859
{ status: 400 }
5960
)
6061
}
6162

62-
const token = await agiloftLogin(data)
63+
const { token, resolvedIP } = await agiloftLogin(data)
6364
const base = data.instanceUrl.replace(/\/$/, '')
6465

6566
try {
@@ -71,7 +72,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
7172
position: data.position,
7273
})
7374

74-
const agiloftResponse = await fetch(url, {
75+
const agiloftResponse = await secureFetchWithPinnedIP(url, resolvedIP, {
7576
method: 'GET',
7677
headers: {
7778
Authorization: `Bearer ${token}`,
@@ -123,7 +124,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
123124
},
124125
})
125126
} finally {
126-
await agiloftLogout(data.instanceUrl, data.knowledgeBase, token)
127+
await agiloftLogout(data.instanceUrl, data.knowledgeBase, token, resolvedIP)
127128
}
128129
} catch (error) {
129130
logger.error(`[${requestId}] Error retrieving Agiloft attachment:`, error)

apps/sim/app/api/tools/microsoft-dataverse/upload-file/route.ts

Lines changed: 18 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { type NextRequest, NextResponse } from 'next/server'
33
import { dataverseUploadFileContract } from '@/lib/api/contracts/tools/microsoft'
44
import { parseRequest } from '@/lib/api/server'
55
import { checkInternalAuth } from '@/lib/auth/hybrid'
6+
import { secureFetchWithValidation } from '@/lib/core/security/input-validation.server'
67
import { generateRequestId } from '@/lib/core/utils/request'
78
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
89
import { processSingleFileToUserFile } from '@/lib/uploads/utils/file-utils'
@@ -78,20 +79,26 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
7879
const baseUrl = validatedData.environmentUrl.replace(/\/$/, '')
7980
const uploadUrl = `${baseUrl}/api/data/v9.2/${validatedData.entitySetName}(${validatedData.recordId})/${validatedData.fileColumn}`
8081

81-
const response = await fetch(uploadUrl, {
82-
method: 'PATCH',
83-
headers: {
84-
Authorization: `Bearer ${validatedData.accessToken}`,
85-
'Content-Type': 'application/octet-stream',
86-
'OData-MaxVersion': '4.0',
87-
'OData-Version': '4.0',
88-
'x-ms-file-name': validatedData.fileName,
82+
const response = await secureFetchWithValidation(
83+
uploadUrl,
84+
{
85+
method: 'PATCH',
86+
headers: {
87+
Authorization: `Bearer ${validatedData.accessToken}`,
88+
'Content-Type': 'application/octet-stream',
89+
'OData-MaxVersion': '4.0',
90+
'OData-Version': '4.0',
91+
'x-ms-file-name': validatedData.fileName,
92+
},
93+
body: fileBuffer,
8994
},
90-
body: new Uint8Array(fileBuffer),
91-
})
95+
'environmentUrl'
96+
)
9297

9398
if (!response.ok) {
94-
const errorData = await response.json().catch(() => ({}))
99+
const errorData = (await response.json().catch(() => ({}))) as {
100+
error?: { message?: string }
101+
}
95102
const errorMessage =
96103
errorData?.error?.message ??
97104
`Dataverse API error: ${response.status} ${response.statusText}`

apps/sim/app/api/v1/logs/[id]/route.ts

Lines changed: 11 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,18 @@
11
import { db } from '@sim/db'
2-
import { permissions, workflow, workflowExecutionLogs } from '@sim/db/schema'
2+
import { workflow, workflowExecutionLogs } from '@sim/db/schema'
33
import { createLogger } from '@sim/logger'
44
import { generateId } from '@sim/utils/id'
5-
import { and, eq } from 'drizzle-orm'
5+
import { eq } from 'drizzle-orm'
66
import { type NextRequest, NextResponse } from 'next/server'
77
import { v1GetLogContract } from '@/lib/api/contracts/v1/logs'
88
import { parseRequest } from '@/lib/api/server'
99
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
1010
import { createApiResponse, getUserLimits } from '@/app/api/v1/logs/meta'
11-
import { checkRateLimit, createRateLimitResponse } from '@/app/api/v1/middleware'
11+
import {
12+
checkRateLimit,
13+
createRateLimitResponse,
14+
validateWorkspaceAccess,
15+
} from '@/app/api/v1/middleware'
1216

1317
const logger = createLogger('V1LogDetailsAPI')
1418

@@ -37,6 +41,7 @@ export const GET = withRouteHandler(
3741
.select({
3842
id: workflowExecutionLogs.id,
3943
workflowId: workflowExecutionLogs.workflowId,
44+
workspaceId: workflowExecutionLogs.workspaceId,
4045
executionId: workflowExecutionLogs.executionId,
4146
stateSnapshotId: workflowExecutionLogs.stateSnapshotId,
4247
level: workflowExecutionLogs.level,
@@ -59,14 +64,6 @@ export const GET = withRouteHandler(
5964
})
6065
.from(workflowExecutionLogs)
6166
.leftJoin(workflow, eq(workflowExecutionLogs.workflowId, workflow.id))
62-
.innerJoin(
63-
permissions,
64-
and(
65-
eq(permissions.entityType, 'workspace'),
66-
eq(permissions.entityId, workflowExecutionLogs.workspaceId),
67-
eq(permissions.userId, userId)
68-
)
69-
)
7067
.where(eq(workflowExecutionLogs.id, id))
7168
.limit(1)
7269

@@ -75,6 +72,9 @@ export const GET = withRouteHandler(
7572
return NextResponse.json({ error: 'Log not found' }, { status: 404 })
7673
}
7774

75+
const accessError = await validateWorkspaceAccess(rateLimit, userId, log.workspaceId)
76+
if (accessError) return accessError
77+
7878
const workflowSummary = {
7979
id: log.workflowId,
8080
name: log.workflowName || 'Deleted Workflow',

0 commit comments

Comments
 (0)