Skip to content

Commit ef14b2b

Browse files
waleedlatif1claude
andauthored
fix(security): remove localhost CORS origin, consolidate CORS in proxy (#4658)
* fix(security): remove localhost CORS origin, consolidate CORS in proxy Move all /api/* CORS handling from next.config.ts to proxy.ts so the runtime can resolve allowed origin per-request instead of baking it at build time (which produced "Access-Control-Allow-Origin: http://localhost:3000" with credentials:true in production). - proxy.ts: per-route CORS policy table covering auth, MCP, form, and workflow execute endpoints; OPTIONS preflight short-circuit; Vary: Origin when origin is not '*'; form routes defer to route handler's addCorsHeaders to avoid double-setting - next.config.ts: drop all /api/* Access-Control-Allow-* headers; keep COEP/COOP/CSP - deployment.ts: addCorsHeaders sets Vary: Origin alongside reflected Allow-Origin - Dockerfile: drop NEXT_PUBLIC_APP_URL build placeholder (Zod has skipValidation:true; build path doesn't read it) - Remove 8 dead OPTIONS handlers and their preflight tests now that the proxy handles preflight uniformly * refactor(cors): consolidate API CORS into proxy as single source of truth Move CORS for /api/chat/* and /api/form/* into the proxy policy table with reflected-origin + credentials:false, and delete the per-route addCorsHeaders helper. Routes no longer set CORS headers — the proxy is the only writer. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * refactor(cors): convert proxy CORS policy chain to a rule table + add tests Replace the if/else chain in resolveApiCorsPolicy with a CORS_RULES table so each route's policy lives in one place and is trivially scannable. Add proxy.test.ts covering each rule and the wildcard-with-credentials invariant. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * fix(cors): scope embed CORS rule to /api/{chat,form}/[identifier] only The embed policy (reflected origin, credentials:false) was matching workspace-internal session-authed routes — /api/chat, /api/chat/manage/*, /api/chat/validate, and the form equivalents — which need the default credentialed policy. Tighten the matcher to the embed paths only and add tests covering the exclusion. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * refactor(cors): replace embed-path regex with explicit segment check The regex form `^/api/(chat|form)/(?!manage|validate)[^/]+(/(otp|sso))?$` was opaque on review and would silently exclude any future identifier subroute outside the hard-coded (otp|sso) group from the embed policy. Replace it with an imperative segment check and a named EMBED_RESERVED_SEGMENTS Set, so the policy boundary is visible at the top of the function and adding a reserved subpath is a one-line diff. Add a test asserting that future identifier subroutes also get the embed policy. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * fix(cors): allow PUT in embed CORS policy for OTP verification Both /api/chat/[identifier]/otp and /api/form/[identifier]/otp export PUT for OTP code verification. The embed policy advertised only GET/POST/OPTIONS, so cross-origin embed clients failed preflight on verify. Add PUT and assert it in the embed policy test. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
1 parent 3930485 commit ef14b2b

26 files changed

Lines changed: 441 additions & 550 deletions

File tree

apps/sim/app/api/chat/[identifier]/otp/route.test.ts

Lines changed: 0 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,6 @@ const {
2626
mockDbUpdate,
2727
mockSendEmail,
2828
mockRenderOTPEmail,
29-
mockAddCorsHeaders,
3029
mockSetChatAuthCookie,
3130
mockGetStorageMethod,
3231
mockZodParse,
@@ -50,7 +49,6 @@ const {
5049
const mockDbUpdate = vi.fn()
5150
const mockSendEmail = vi.fn()
5251
const mockRenderOTPEmail = vi.fn()
53-
const mockAddCorsHeaders = vi.fn()
5452
const mockSetChatAuthCookie = vi.fn()
5553
const mockGetStorageMethod = vi.fn()
5654
const mockZodParse = vi.fn()
@@ -69,7 +67,6 @@ const {
6967
mockDbUpdate,
7068
mockSendEmail,
7169
mockRenderOTPEmail,
72-
mockAddCorsHeaders,
7370
mockSetChatAuthCookie,
7471
mockGetStorageMethod,
7572
mockZodParse,
@@ -131,7 +128,6 @@ vi.mock('@/components/emails', () => ({
131128
}))
132129

133130
vi.mock('@/lib/core/security/deployment', () => ({
134-
addCorsHeaders: mockAddCorsHeaders,
135131
isEmailAllowed: (email: string, allowedEmails: string[]) => {
136132
if (allowedEmails.includes(email)) return true
137133
const atIndex = email.indexOf('@')
@@ -248,7 +244,6 @@ describe('Chat OTP API Route', () => {
248244
mockSendEmail.mockResolvedValue({ success: true })
249245
mockRenderOTPEmail.mockResolvedValue('<html>OTP Email</html>')
250246

251-
mockAddCorsHeaders.mockImplementation((response: unknown) => response)
252247
mockCreateSuccessResponse.mockImplementation((data: unknown) => ({
253248
json: () => Promise.resolve(data),
254249
status: 200,

apps/sim/app/api/chat/[identifier]/otp/route.ts

Lines changed: 25 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import { renderOTPEmail } from '@/components/emails'
77
import { requestChatEmailOtpContract, verifyChatEmailOtpContract } from '@/lib/api/contracts/chats'
88
import { getValidationErrorMessage, parseRequest } from '@/lib/api/server'
99
import { RateLimiter } from '@/lib/core/rate-limiter'
10-
import { addCorsHeaders, isEmailAllowed } from '@/lib/core/security/deployment'
10+
import { isEmailAllowed } from '@/lib/core/security/deployment'
1111
import {
1212
decodeOTPValue,
1313
deleteOTP,
@@ -47,15 +47,12 @@ export const POST = withRouteHandler(
4747
)
4848
const response = createErrorResponse('Too many requests. Please try again later.', 429)
4949
response.headers.set('Retry-After', String(retryAfter))
50-
return addCorsHeaders(response, request)
50+
return response
5151
}
5252

5353
const parsed = await parseRequest(requestChatEmailOtpContract, request, context, {
5454
validationErrorResponse: (error) =>
55-
addCorsHeaders(
56-
createErrorResponse(getValidationErrorMessage(error, 'Invalid request'), 400),
57-
request
58-
),
55+
createErrorResponse(getValidationErrorMessage(error, 'Invalid request'), 400),
5956
})
6057
if (!parsed.success) return parsed.response
6158
const { email } = parsed.data.body
@@ -75,27 +72,21 @@ export const POST = withRouteHandler(
7572

7673
if (deploymentResult.length === 0) {
7774
logger.warn(`[${requestId}] Chat not found for identifier: ${identifier}`)
78-
return addCorsHeaders(createErrorResponse('Chat not found', 404), request)
75+
return createErrorResponse('Chat not found', 404)
7976
}
8077

8178
const deployment = deploymentResult[0]
8279

8380
if (deployment.authType !== 'email') {
84-
return addCorsHeaders(
85-
createErrorResponse('This chat does not use email authentication', 400),
86-
request
87-
)
81+
return createErrorResponse('This chat does not use email authentication', 400)
8882
}
8983

9084
const allowedEmails: string[] = Array.isArray(deployment.allowedEmails)
9185
? deployment.allowedEmails
9286
: []
9387

9488
if (!isEmailAllowed(email, allowedEmails)) {
95-
return addCorsHeaders(
96-
createErrorResponse('Email not authorized for this chat', 403),
97-
request
98-
)
89+
return createErrorResponse('Email not authorized for this chat', 403)
9990
}
10091

10192
const emailRateLimit = await rateLimiter.checkRateLimitDirect(
@@ -114,7 +105,7 @@ export const POST = withRouteHandler(
114105
429
115106
)
116107
response.headers.set('Retry-After', String(retryAfter))
117-
return addCorsHeaders(response, request)
108+
return response
118109
}
119110

120111
const otp = generateOTP()
@@ -135,17 +126,14 @@ export const POST = withRouteHandler(
135126

136127
if (!emailResult.success) {
137128
logger.error(`[${requestId}] Failed to send OTP email:`, emailResult.message)
138-
return addCorsHeaders(
139-
createErrorResponse('Failed to send verification email', 500),
140-
request
141-
)
129+
return createErrorResponse('Failed to send verification email', 500)
142130
}
143131

144132
logger.info(`[${requestId}] OTP sent to ${email} for chat ${deployment.id}`)
145-
return addCorsHeaders(createSuccessResponse({ message: 'Verification code sent' }), request)
133+
return createSuccessResponse({ message: 'Verification code sent' })
146134
} catch (error) {
147135
logger.error(`[${requestId}] Error processing OTP request:`, error)
148-
return addCorsHeaders(createErrorResponse('Failed to process request', 500), request)
136+
return createErrorResponse('Failed to process request', 500)
149137
}
150138
}
151139
)
@@ -158,10 +146,7 @@ export const PUT = withRouteHandler(
158146
try {
159147
const parsed = await parseRequest(verifyChatEmailOtpContract, request, context, {
160148
validationErrorResponse: (error) =>
161-
addCorsHeaders(
162-
createErrorResponse(getValidationErrorMessage(error, 'Invalid request'), 400),
163-
request
164-
),
149+
createErrorResponse(getValidationErrorMessage(error, 'Invalid request'), 400),
165150
})
166151
if (!parsed.success) return parsed.response
167152
const { email, otp } = parsed.data.body
@@ -184,61 +169,49 @@ export const PUT = withRouteHandler(
184169

185170
if (deploymentResult.length === 0) {
186171
logger.warn(`[${requestId}] Chat not found for identifier: ${identifier}`)
187-
return addCorsHeaders(createErrorResponse('Chat not found', 404), request)
172+
return createErrorResponse('Chat not found', 404)
188173
}
189174

190175
const deployment = deploymentResult[0]
191176

192177
const storedValue = await getOTP('chat', deployment.id, email)
193178
if (!storedValue) {
194-
return addCorsHeaders(
195-
createErrorResponse('No verification code found, request a new one', 400),
196-
request
197-
)
179+
return createErrorResponse('No verification code found, request a new one', 400)
198180
}
199181

200182
const { otp: storedOTP, attempts } = decodeOTPValue(storedValue)
201183

202184
if (attempts >= MAX_OTP_ATTEMPTS) {
203185
await deleteOTP('chat', deployment.id, email)
204186
logger.warn(`[${requestId}] OTP already locked out for ${email}`)
205-
return addCorsHeaders(
206-
createErrorResponse('Too many failed attempts. Please request a new code.', 429),
207-
request
208-
)
187+
return createErrorResponse('Too many failed attempts. Please request a new code.', 429)
209188
}
210189

211190
if (storedOTP !== otp) {
212191
const result = await incrementOTPAttempts('chat', deployment.id, email, storedValue)
213192
if (result === 'locked') {
214193
logger.warn(`[${requestId}] OTP invalidated after max failed attempts for ${email}`)
215-
return addCorsHeaders(
216-
createErrorResponse('Too many failed attempts. Please request a new code.', 429),
217-
request
218-
)
194+
return createErrorResponse('Too many failed attempts. Please request a new code.', 429)
219195
}
220-
return addCorsHeaders(createErrorResponse('Invalid verification code', 400), request)
196+
return createErrorResponse('Invalid verification code', 400)
221197
}
222198

223199
await deleteOTP('chat', deployment.id, email)
224200

225-
const response = addCorsHeaders(
226-
createSuccessResponse({
227-
id: deployment.id,
228-
title: deployment.title,
229-
description: deployment.description,
230-
customizations: deployment.customizations,
231-
authType: deployment.authType,
232-
outputConfigs: deployment.outputConfigs,
233-
}),
234-
request
235-
)
201+
const response = createSuccessResponse({
202+
id: deployment.id,
203+
title: deployment.title,
204+
description: deployment.description,
205+
customizations: deployment.customizations,
206+
authType: deployment.authType,
207+
outputConfigs: deployment.outputConfigs,
208+
})
236209
setChatAuthCookie(response, deployment.id, deployment.authType, deployment.password)
237210

238211
return response
239212
} catch (error) {
240213
logger.error(`[${requestId}] Error verifying OTP:`, error)
241-
return addCorsHeaders(createErrorResponse('Failed to process request', 500), request)
214+
return createErrorResponse('Failed to process request', 500)
242215
}
243216
}
244217
)

apps/sim/app/api/chat/[identifier]/route.test.ts

Lines changed: 5 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -63,13 +63,11 @@ const createMockStream = () => {
6363
})
6464
}
6565

66-
const { mockAddCorsHeaders, mockValidateChatAuth, mockSetChatAuthCookie, mockValidateAuthToken } =
67-
vi.hoisted(() => ({
68-
mockAddCorsHeaders: vi.fn().mockImplementation((response: Response) => response),
69-
mockValidateChatAuth: vi.fn().mockResolvedValue({ authorized: true }),
70-
mockSetChatAuthCookie: vi.fn(),
71-
mockValidateAuthToken: vi.fn().mockReturnValue(false),
72-
}))
66+
const { mockValidateChatAuth, mockSetChatAuthCookie, mockValidateAuthToken } = vi.hoisted(() => ({
67+
mockValidateChatAuth: vi.fn().mockResolvedValue({ authorized: true }),
68+
mockSetChatAuthCookie: vi.fn(),
69+
mockValidateAuthToken: vi.fn().mockReturnValue(false),
70+
}))
7371

7472
const mockCreateErrorResponse = workflowsApiUtilsMockFns.mockCreateErrorResponse
7573
const mockCreateSuccessResponse = workflowsApiUtilsMockFns.mockCreateSuccessResponse
@@ -81,7 +79,6 @@ vi.mock('@sim/db', () => ({
8179
}))
8280

8381
vi.mock('@/lib/core/security/deployment', () => ({
84-
addCorsHeaders: mockAddCorsHeaders,
8582
validateAuthToken: mockValidateAuthToken,
8683
setDeploymentAuthCookie: vi.fn(),
8784
isEmailAllowed: vi.fn().mockReturnValue(false),
@@ -181,7 +178,6 @@ describe('Chat Identifier API Route', () => {
181178
},
182179
})
183180

184-
mockAddCorsHeaders.mockImplementation((response: Response) => response)
185181
mockValidateChatAuth.mockResolvedValue({ authorized: true })
186182
mockValidateAuthToken.mockReturnValue(false)
187183
mockCreateErrorResponse.mockImplementation((message: string, status: number, code?: string) => {

0 commit comments

Comments
 (0)