mirror of
https://github.com/simstudioai/sim.git
synced 2026-09-24 15:45:35 +08:00
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>
This commit is contained in:
co-authored by
Claude Opus 4.7
parent
3930485dff
commit
ef14b2b580
@@ -26,7 +26,6 @@ const {
|
||||
mockDbUpdate,
|
||||
mockSendEmail,
|
||||
mockRenderOTPEmail,
|
||||
mockAddCorsHeaders,
|
||||
mockSetChatAuthCookie,
|
||||
mockGetStorageMethod,
|
||||
mockZodParse,
|
||||
@@ -50,7 +49,6 @@ const {
|
||||
const mockDbUpdate = vi.fn()
|
||||
const mockSendEmail = vi.fn()
|
||||
const mockRenderOTPEmail = vi.fn()
|
||||
const mockAddCorsHeaders = vi.fn()
|
||||
const mockSetChatAuthCookie = vi.fn()
|
||||
const mockGetStorageMethod = vi.fn()
|
||||
const mockZodParse = vi.fn()
|
||||
@@ -69,7 +67,6 @@ const {
|
||||
mockDbUpdate,
|
||||
mockSendEmail,
|
||||
mockRenderOTPEmail,
|
||||
mockAddCorsHeaders,
|
||||
mockSetChatAuthCookie,
|
||||
mockGetStorageMethod,
|
||||
mockZodParse,
|
||||
@@ -131,7 +128,6 @@ vi.mock('@/components/emails', () => ({
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/core/security/deployment', () => ({
|
||||
addCorsHeaders: mockAddCorsHeaders,
|
||||
isEmailAllowed: (email: string, allowedEmails: string[]) => {
|
||||
if (allowedEmails.includes(email)) return true
|
||||
const atIndex = email.indexOf('@')
|
||||
@@ -248,7 +244,6 @@ describe('Chat OTP API Route', () => {
|
||||
mockSendEmail.mockResolvedValue({ success: true })
|
||||
mockRenderOTPEmail.mockResolvedValue('<html>OTP Email</html>')
|
||||
|
||||
mockAddCorsHeaders.mockImplementation((response: unknown) => response)
|
||||
mockCreateSuccessResponse.mockImplementation((data: unknown) => ({
|
||||
json: () => Promise.resolve(data),
|
||||
status: 200,
|
||||
|
||||
@@ -7,7 +7,7 @@ import { renderOTPEmail } from '@/components/emails'
|
||||
import { requestChatEmailOtpContract, verifyChatEmailOtpContract } from '@/lib/api/contracts/chats'
|
||||
import { getValidationErrorMessage, parseRequest } from '@/lib/api/server'
|
||||
import { RateLimiter } from '@/lib/core/rate-limiter'
|
||||
import { addCorsHeaders, isEmailAllowed } from '@/lib/core/security/deployment'
|
||||
import { isEmailAllowed } from '@/lib/core/security/deployment'
|
||||
import {
|
||||
decodeOTPValue,
|
||||
deleteOTP,
|
||||
@@ -47,15 +47,12 @@ export const POST = withRouteHandler(
|
||||
)
|
||||
const response = createErrorResponse('Too many requests. Please try again later.', 429)
|
||||
response.headers.set('Retry-After', String(retryAfter))
|
||||
return addCorsHeaders(response, request)
|
||||
return response
|
||||
}
|
||||
|
||||
const parsed = await parseRequest(requestChatEmailOtpContract, request, context, {
|
||||
validationErrorResponse: (error) =>
|
||||
addCorsHeaders(
|
||||
createErrorResponse(getValidationErrorMessage(error, 'Invalid request'), 400),
|
||||
request
|
||||
),
|
||||
createErrorResponse(getValidationErrorMessage(error, 'Invalid request'), 400),
|
||||
})
|
||||
if (!parsed.success) return parsed.response
|
||||
const { email } = parsed.data.body
|
||||
@@ -75,16 +72,13 @@ export const POST = withRouteHandler(
|
||||
|
||||
if (deploymentResult.length === 0) {
|
||||
logger.warn(`[${requestId}] Chat not found for identifier: ${identifier}`)
|
||||
return addCorsHeaders(createErrorResponse('Chat not found', 404), request)
|
||||
return createErrorResponse('Chat not found', 404)
|
||||
}
|
||||
|
||||
const deployment = deploymentResult[0]
|
||||
|
||||
if (deployment.authType !== 'email') {
|
||||
return addCorsHeaders(
|
||||
createErrorResponse('This chat does not use email authentication', 400),
|
||||
request
|
||||
)
|
||||
return createErrorResponse('This chat does not use email authentication', 400)
|
||||
}
|
||||
|
||||
const allowedEmails: string[] = Array.isArray(deployment.allowedEmails)
|
||||
@@ -92,10 +86,7 @@ export const POST = withRouteHandler(
|
||||
: []
|
||||
|
||||
if (!isEmailAllowed(email, allowedEmails)) {
|
||||
return addCorsHeaders(
|
||||
createErrorResponse('Email not authorized for this chat', 403),
|
||||
request
|
||||
)
|
||||
return createErrorResponse('Email not authorized for this chat', 403)
|
||||
}
|
||||
|
||||
const emailRateLimit = await rateLimiter.checkRateLimitDirect(
|
||||
@@ -114,7 +105,7 @@ export const POST = withRouteHandler(
|
||||
429
|
||||
)
|
||||
response.headers.set('Retry-After', String(retryAfter))
|
||||
return addCorsHeaders(response, request)
|
||||
return response
|
||||
}
|
||||
|
||||
const otp = generateOTP()
|
||||
@@ -135,17 +126,14 @@ export const POST = withRouteHandler(
|
||||
|
||||
if (!emailResult.success) {
|
||||
logger.error(`[${requestId}] Failed to send OTP email:`, emailResult.message)
|
||||
return addCorsHeaders(
|
||||
createErrorResponse('Failed to send verification email', 500),
|
||||
request
|
||||
)
|
||||
return createErrorResponse('Failed to send verification email', 500)
|
||||
}
|
||||
|
||||
logger.info(`[${requestId}] OTP sent to ${email} for chat ${deployment.id}`)
|
||||
return addCorsHeaders(createSuccessResponse({ message: 'Verification code sent' }), request)
|
||||
return createSuccessResponse({ message: 'Verification code sent' })
|
||||
} catch (error) {
|
||||
logger.error(`[${requestId}] Error processing OTP request:`, error)
|
||||
return addCorsHeaders(createErrorResponse('Failed to process request', 500), request)
|
||||
return createErrorResponse('Failed to process request', 500)
|
||||
}
|
||||
}
|
||||
)
|
||||
@@ -158,10 +146,7 @@ export const PUT = withRouteHandler(
|
||||
try {
|
||||
const parsed = await parseRequest(verifyChatEmailOtpContract, request, context, {
|
||||
validationErrorResponse: (error) =>
|
||||
addCorsHeaders(
|
||||
createErrorResponse(getValidationErrorMessage(error, 'Invalid request'), 400),
|
||||
request
|
||||
),
|
||||
createErrorResponse(getValidationErrorMessage(error, 'Invalid request'), 400),
|
||||
})
|
||||
if (!parsed.success) return parsed.response
|
||||
const { email, otp } = parsed.data.body
|
||||
@@ -184,17 +169,14 @@ export const PUT = withRouteHandler(
|
||||
|
||||
if (deploymentResult.length === 0) {
|
||||
logger.warn(`[${requestId}] Chat not found for identifier: ${identifier}`)
|
||||
return addCorsHeaders(createErrorResponse('Chat not found', 404), request)
|
||||
return createErrorResponse('Chat not found', 404)
|
||||
}
|
||||
|
||||
const deployment = deploymentResult[0]
|
||||
|
||||
const storedValue = await getOTP('chat', deployment.id, email)
|
||||
if (!storedValue) {
|
||||
return addCorsHeaders(
|
||||
createErrorResponse('No verification code found, request a new one', 400),
|
||||
request
|
||||
)
|
||||
return createErrorResponse('No verification code found, request a new one', 400)
|
||||
}
|
||||
|
||||
const { otp: storedOTP, attempts } = decodeOTPValue(storedValue)
|
||||
@@ -202,43 +184,34 @@ export const PUT = withRouteHandler(
|
||||
if (attempts >= MAX_OTP_ATTEMPTS) {
|
||||
await deleteOTP('chat', deployment.id, email)
|
||||
logger.warn(`[${requestId}] OTP already locked out for ${email}`)
|
||||
return addCorsHeaders(
|
||||
createErrorResponse('Too many failed attempts. Please request a new code.', 429),
|
||||
request
|
||||
)
|
||||
return createErrorResponse('Too many failed attempts. Please request a new code.', 429)
|
||||
}
|
||||
|
||||
if (storedOTP !== otp) {
|
||||
const result = await incrementOTPAttempts('chat', deployment.id, email, storedValue)
|
||||
if (result === 'locked') {
|
||||
logger.warn(`[${requestId}] OTP invalidated after max failed attempts for ${email}`)
|
||||
return addCorsHeaders(
|
||||
createErrorResponse('Too many failed attempts. Please request a new code.', 429),
|
||||
request
|
||||
)
|
||||
return createErrorResponse('Too many failed attempts. Please request a new code.', 429)
|
||||
}
|
||||
return addCorsHeaders(createErrorResponse('Invalid verification code', 400), request)
|
||||
return createErrorResponse('Invalid verification code', 400)
|
||||
}
|
||||
|
||||
await deleteOTP('chat', deployment.id, email)
|
||||
|
||||
const response = addCorsHeaders(
|
||||
createSuccessResponse({
|
||||
id: deployment.id,
|
||||
title: deployment.title,
|
||||
description: deployment.description,
|
||||
customizations: deployment.customizations,
|
||||
authType: deployment.authType,
|
||||
outputConfigs: deployment.outputConfigs,
|
||||
}),
|
||||
request
|
||||
)
|
||||
const response = createSuccessResponse({
|
||||
id: deployment.id,
|
||||
title: deployment.title,
|
||||
description: deployment.description,
|
||||
customizations: deployment.customizations,
|
||||
authType: deployment.authType,
|
||||
outputConfigs: deployment.outputConfigs,
|
||||
})
|
||||
setChatAuthCookie(response, deployment.id, deployment.authType, deployment.password)
|
||||
|
||||
return response
|
||||
} catch (error) {
|
||||
logger.error(`[${requestId}] Error verifying OTP:`, error)
|
||||
return addCorsHeaders(createErrorResponse('Failed to process request', 500), request)
|
||||
return createErrorResponse('Failed to process request', 500)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
@@ -63,13 +63,11 @@ const createMockStream = () => {
|
||||
})
|
||||
}
|
||||
|
||||
const { mockAddCorsHeaders, mockValidateChatAuth, mockSetChatAuthCookie, mockValidateAuthToken } =
|
||||
vi.hoisted(() => ({
|
||||
mockAddCorsHeaders: vi.fn().mockImplementation((response: Response) => response),
|
||||
mockValidateChatAuth: vi.fn().mockResolvedValue({ authorized: true }),
|
||||
mockSetChatAuthCookie: vi.fn(),
|
||||
mockValidateAuthToken: vi.fn().mockReturnValue(false),
|
||||
}))
|
||||
const { mockValidateChatAuth, mockSetChatAuthCookie, mockValidateAuthToken } = vi.hoisted(() => ({
|
||||
mockValidateChatAuth: vi.fn().mockResolvedValue({ authorized: true }),
|
||||
mockSetChatAuthCookie: vi.fn(),
|
||||
mockValidateAuthToken: vi.fn().mockReturnValue(false),
|
||||
}))
|
||||
|
||||
const mockCreateErrorResponse = workflowsApiUtilsMockFns.mockCreateErrorResponse
|
||||
const mockCreateSuccessResponse = workflowsApiUtilsMockFns.mockCreateSuccessResponse
|
||||
@@ -81,7 +79,6 @@ vi.mock('@sim/db', () => ({
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/core/security/deployment', () => ({
|
||||
addCorsHeaders: mockAddCorsHeaders,
|
||||
validateAuthToken: mockValidateAuthToken,
|
||||
setDeploymentAuthCookie: vi.fn(),
|
||||
isEmailAllowed: vi.fn().mockReturnValue(false),
|
||||
@@ -181,7 +178,6 @@ describe('Chat Identifier API Route', () => {
|
||||
},
|
||||
})
|
||||
|
||||
mockAddCorsHeaders.mockImplementation((response: Response) => response)
|
||||
mockValidateChatAuth.mockResolvedValue({ authorized: true })
|
||||
mockValidateAuthToken.mockReturnValue(false)
|
||||
mockCreateErrorResponse.mockImplementation((message: string, status: number, code?: string) => {
|
||||
|
||||
@@ -6,7 +6,7 @@ import { and, eq, isNull } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { deployedChatPostContract } from '@/lib/api/contracts/chats'
|
||||
import { parseRequest } from '@/lib/api/server'
|
||||
import { addCorsHeaders, validateAuthToken } from '@/lib/core/security/deployment'
|
||||
import { validateAuthToken } from '@/lib/core/security/deployment'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
import { preprocessExecution } from '@/lib/execution/preprocessing'
|
||||
@@ -49,13 +49,9 @@ export const POST = withRouteHandler(
|
||||
const parsed = await parseRequest(deployedChatPostContract, request, context, {
|
||||
validationErrorResponse: (err) => {
|
||||
const message = err.issues.map((e) => `${e.path.join('.')}: ${e.message}`).join(', ')
|
||||
return addCorsHeaders(
|
||||
createErrorResponse(`Invalid request body: ${message}`, 400, 'VALIDATION_ERROR'),
|
||||
request
|
||||
)
|
||||
return createErrorResponse(`Invalid request body: ${message}`, 400, 'VALIDATION_ERROR')
|
||||
},
|
||||
invalidJsonResponse: () =>
|
||||
addCorsHeaders(createErrorResponse('Invalid request body', 400), request),
|
||||
invalidJsonResponse: () => createErrorResponse('Invalid request body', 400),
|
||||
})
|
||||
if (!parsed.success) return parsed.response
|
||||
const parsedBody = parsed.data.body
|
||||
@@ -80,7 +76,7 @@ export const POST = withRouteHandler(
|
||||
|
||||
if (deploymentResult.length === 0) {
|
||||
logger.warn(`[${requestId}] Chat not found for identifier: ${identifier}`)
|
||||
return addCorsHeaders(createErrorResponse('Chat not found', 404), request)
|
||||
return createErrorResponse('Chat not found', 404)
|
||||
}
|
||||
|
||||
const deployment = deploymentResult[0]
|
||||
@@ -99,10 +95,7 @@ export const POST = withRouteHandler(
|
||||
logger.warn(
|
||||
`[${requestId}] Cannot log: workflow ${deployment.workflowId} has no workspace`
|
||||
)
|
||||
return addCorsHeaders(
|
||||
createErrorResponse('This chat is currently unavailable', 403),
|
||||
request
|
||||
)
|
||||
return createErrorResponse('This chat is currently unavailable', 403)
|
||||
}
|
||||
|
||||
const executionId = generateId()
|
||||
@@ -127,27 +120,18 @@ export const POST = withRouteHandler(
|
||||
traceSpans: [],
|
||||
})
|
||||
|
||||
return addCorsHeaders(
|
||||
createErrorResponse('This chat is currently unavailable', 403),
|
||||
request
|
||||
)
|
||||
return createErrorResponse('This chat is currently unavailable', 403)
|
||||
}
|
||||
|
||||
const authResult = await validateChatAuth(requestId, deployment, request, parsedBody)
|
||||
if (!authResult.authorized) {
|
||||
return addCorsHeaders(
|
||||
createErrorResponse(authResult.error || 'Authentication required', 401),
|
||||
request
|
||||
)
|
||||
return createErrorResponse(authResult.error || 'Authentication required', 401)
|
||||
}
|
||||
|
||||
const { input, password, email, conversationId, files } = parsedBody
|
||||
|
||||
if ((password || email) && !input) {
|
||||
const response = addCorsHeaders(
|
||||
createSuccessResponse(toChatConfigResponse(deployment)),
|
||||
request
|
||||
)
|
||||
const response = createSuccessResponse(toChatConfigResponse(deployment))
|
||||
|
||||
if (deployment.authType !== 'sso') {
|
||||
setChatAuthCookie(response, deployment.id, deployment.authType, deployment.password)
|
||||
@@ -157,7 +141,7 @@ export const POST = withRouteHandler(
|
||||
}
|
||||
|
||||
if (!input && (!files || files.length === 0)) {
|
||||
return addCorsHeaders(createErrorResponse('No input provided', 400), request)
|
||||
return createErrorResponse('No input provided', 400)
|
||||
}
|
||||
|
||||
const executionId = generateId()
|
||||
@@ -182,12 +166,9 @@ export const POST = withRouteHandler(
|
||||
|
||||
if (!preprocessResult.success) {
|
||||
logger.warn(`[${requestId}] Preprocessing failed: ${preprocessResult.error?.message}`)
|
||||
return addCorsHeaders(
|
||||
createErrorResponse(
|
||||
preprocessResult.error?.message || 'Failed to process request',
|
||||
preprocessResult.error?.statusCode || 500
|
||||
),
|
||||
request
|
||||
return createErrorResponse(
|
||||
preprocessResult.error?.message || 'Failed to process request',
|
||||
preprocessResult.error?.statusCode || 500
|
||||
)
|
||||
}
|
||||
|
||||
@@ -196,10 +177,7 @@ export const POST = withRouteHandler(
|
||||
const workspaceId = workflowRecord?.workspaceId
|
||||
if (!workspaceId) {
|
||||
logger.error(`[${requestId}] Workflow ${deployment.workflowId} has no workspaceId`)
|
||||
return addCorsHeaders(
|
||||
createErrorResponse('Workflow has no associated workspace', 500),
|
||||
request
|
||||
)
|
||||
return createErrorResponse('Workflow has no associated workspace', 500)
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -302,20 +280,14 @@ export const POST = withRouteHandler(
|
||||
status: 200,
|
||||
headers: SSE_HEADERS,
|
||||
})
|
||||
return addCorsHeaders(streamResponse, request)
|
||||
return streamResponse
|
||||
} catch (error: any) {
|
||||
logger.error(`[${requestId}] Error processing chat request:`, error)
|
||||
return addCorsHeaders(
|
||||
createErrorResponse(error.message || 'Failed to process request', 500),
|
||||
request
|
||||
)
|
||||
return createErrorResponse(error.message || 'Failed to process request', 500)
|
||||
}
|
||||
} catch (error: any) {
|
||||
logger.error(`[${requestId}] Error processing chat request:`, error)
|
||||
return addCorsHeaders(
|
||||
createErrorResponse(error.message || 'Failed to process request', 500),
|
||||
request
|
||||
)
|
||||
return createErrorResponse(error.message || 'Failed to process request', 500)
|
||||
}
|
||||
}
|
||||
)
|
||||
@@ -345,17 +317,14 @@ export const GET = withRouteHandler(
|
||||
|
||||
if (deploymentResult.length === 0) {
|
||||
logger.warn(`[${requestId}] Chat not found for identifier: ${identifier}`)
|
||||
return addCorsHeaders(createErrorResponse('Chat not found', 404), request)
|
||||
return createErrorResponse('Chat not found', 404)
|
||||
}
|
||||
|
||||
const deployment = deploymentResult[0]
|
||||
|
||||
if (!deployment.isActive) {
|
||||
logger.warn(`[${requestId}] Chat is not active: ${identifier}`)
|
||||
return addCorsHeaders(
|
||||
createErrorResponse('This chat is currently unavailable', 403),
|
||||
request
|
||||
)
|
||||
return createErrorResponse('This chat is currently unavailable', 403)
|
||||
}
|
||||
|
||||
const cookieName = `chat_auth_${deployment.id}`
|
||||
@@ -367,7 +336,7 @@ export const GET = withRouteHandler(
|
||||
authCookie &&
|
||||
validateAuthToken(authCookie.value, deployment.id, deployment.password)
|
||||
) {
|
||||
return addCorsHeaders(createSuccessResponse(toChatConfigResponse(deployment)), request)
|
||||
return createSuccessResponse(toChatConfigResponse(deployment))
|
||||
}
|
||||
|
||||
const authResult = await validateChatAuth(requestId, deployment, request)
|
||||
@@ -375,19 +344,13 @@ export const GET = withRouteHandler(
|
||||
logger.info(
|
||||
`[${requestId}] Authentication required for chat: ${identifier}, type: ${deployment.authType}`
|
||||
)
|
||||
return addCorsHeaders(
|
||||
createErrorResponse(authResult.error || 'Authentication required', 401),
|
||||
request
|
||||
)
|
||||
return createErrorResponse(authResult.error || 'Authentication required', 401)
|
||||
}
|
||||
|
||||
return addCorsHeaders(createSuccessResponse(toChatConfigResponse(deployment)), request)
|
||||
return createSuccessResponse(toChatConfigResponse(deployment))
|
||||
} catch (error: any) {
|
||||
logger.error(`[${requestId}] Error fetching chat info:`, error)
|
||||
return addCorsHeaders(
|
||||
createErrorResponse(error.message || 'Failed to fetch chat information', 500),
|
||||
request
|
||||
)
|
||||
return createErrorResponse(error.message || 'Failed to fetch chat information', 500)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
@@ -7,7 +7,7 @@ import { chatSSOContract } from '@/lib/api/contracts/chats'
|
||||
import { parseRequest } from '@/lib/api/server'
|
||||
import type { TokenBucketConfig } from '@/lib/core/rate-limiter'
|
||||
import { RateLimiter } from '@/lib/core/rate-limiter'
|
||||
import { addCorsHeaders, isEmailAllowed } from '@/lib/core/security/deployment'
|
||||
import { isEmailAllowed } from '@/lib/core/security/deployment'
|
||||
import { generateRequestId, getClientIp } from '@/lib/core/utils/request'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
import { createErrorResponse, createSuccessResponse } from '@/app/api/workflows/utils'
|
||||
@@ -41,7 +41,7 @@ export const POST = withRouteHandler(
|
||||
)
|
||||
const response = createErrorResponse('Too many requests. Please try again later.', 429)
|
||||
response.headers.set('Retry-After', String(retryAfter))
|
||||
return addCorsHeaders(response, request)
|
||||
return response
|
||||
}
|
||||
|
||||
const parsed = await parseRequest(chatSSOContract, request, context)
|
||||
@@ -62,18 +62,15 @@ export const POST = withRouteHandler(
|
||||
|
||||
if (!deployment || !deployment.isActive) {
|
||||
logger.warn(`[${requestId}] SSO check on missing/inactive chat: ${identifier}`)
|
||||
return addCorsHeaders(createErrorResponse('Chat not found', 404), request)
|
||||
return createErrorResponse('Chat not found', 404)
|
||||
}
|
||||
|
||||
if (deployment.authType !== 'sso') {
|
||||
return addCorsHeaders(
|
||||
createErrorResponse('Chat is not configured for SSO authentication', 400),
|
||||
request
|
||||
)
|
||||
return createErrorResponse('Chat is not configured for SSO authentication', 400)
|
||||
}
|
||||
|
||||
const eligible = isEmailAllowed(email, (deployment.allowedEmails as string[]) || [])
|
||||
|
||||
return addCorsHeaders(createSuccessResponse({ eligible }), request)
|
||||
return createSuccessResponse({ eligible })
|
||||
}
|
||||
)
|
||||
|
||||
@@ -17,7 +17,6 @@ const {
|
||||
mockMergeSubBlockValues,
|
||||
mockValidateAuthToken,
|
||||
mockSetDeploymentAuthCookie,
|
||||
mockAddCorsHeaders,
|
||||
mockIsEmailAllowed,
|
||||
mockGetSession,
|
||||
} = vi.hoisted(() => ({
|
||||
@@ -25,7 +24,6 @@ const {
|
||||
mockMergeSubBlockValues: vi.fn().mockReturnValue({}),
|
||||
mockValidateAuthToken: vi.fn().mockReturnValue(false),
|
||||
mockSetDeploymentAuthCookie: vi.fn(),
|
||||
mockAddCorsHeaders: vi.fn((response: unknown) => response),
|
||||
mockIsEmailAllowed: vi.fn(),
|
||||
mockGetSession: vi.fn(),
|
||||
}))
|
||||
@@ -57,7 +55,6 @@ vi.mock('@/lib/core/security/encryption', () => encryptionMock)
|
||||
vi.mock('@/lib/core/security/deployment', () => ({
|
||||
validateAuthToken: mockValidateAuthToken,
|
||||
setDeploymentAuthCookie: mockSetDeploymentAuthCookie,
|
||||
addCorsHeaders: mockAddCorsHeaders,
|
||||
isEmailAllowed: mockIsEmailAllowed,
|
||||
}))
|
||||
|
||||
|
||||
@@ -91,7 +91,7 @@ vi.mock('fs/promises', () => ({
|
||||
}))
|
||||
|
||||
import { createMockRequest } from '@sim/testing'
|
||||
import { OPTIONS, POST } from '@/app/api/files/delete/route'
|
||||
import { POST } from '@/app/api/files/delete/route'
|
||||
|
||||
describe('File Delete API Route', () => {
|
||||
beforeEach(() => {
|
||||
@@ -198,12 +198,4 @@ describe('File Delete API Route', () => {
|
||||
expect(data).toHaveProperty('error', 'InvalidRequestError')
|
||||
expect(data).toHaveProperty('message', 'No file path provided')
|
||||
})
|
||||
|
||||
it('should handle CORS preflight requests', async () => {
|
||||
const response = await OPTIONS()
|
||||
|
||||
expect(response.status).toBe(204)
|
||||
expect(response.headers.get('Access-Control-Allow-Methods')).toBe('GET, POST, DELETE, OPTIONS')
|
||||
expect(response.headers.get('Access-Control-Allow-Headers')).toBe('Content-Type')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -12,7 +12,6 @@ import { extractStorageKey, inferContextFromKey } from '@/lib/uploads/utils/file
|
||||
import { verifyFileAccess } from '@/app/api/files/authorization'
|
||||
import {
|
||||
createErrorResponse,
|
||||
createOptionsResponse,
|
||||
createSuccessResponse,
|
||||
extractFilename,
|
||||
FileNotFoundError,
|
||||
@@ -119,10 +118,3 @@ function extractStorageKeyFromPath(filePath: string): string {
|
||||
|
||||
return extractFilename(filePath)
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle CORS preflight requests
|
||||
*/
|
||||
export const OPTIONS = withRouteHandler(async () => {
|
||||
return createOptionsResponse()
|
||||
})
|
||||
|
||||
@@ -156,17 +156,3 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
export const OPTIONS = withRouteHandler(async () => {
|
||||
return NextResponse.json(
|
||||
{},
|
||||
{
|
||||
status: 200,
|
||||
headers: {
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
'Access-Control-Allow-Methods': 'POST, OPTIONS',
|
||||
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
|
||||
},
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
@@ -107,7 +107,7 @@ vi.mock('@/lib/uploads', () => ({
|
||||
isUsingCloudStorage: mockIsUsingCloudStorageUploads,
|
||||
}))
|
||||
|
||||
import { OPTIONS, POST } from '@/app/api/files/presigned/route'
|
||||
import { POST } from '@/app/api/files/presigned/route'
|
||||
|
||||
const defaultMockUser = {
|
||||
id: 'test-user-id',
|
||||
@@ -827,16 +827,4 @@ describe('/api/files/presigned', () => {
|
||||
expect(mockValidateAttachmentFileType).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('OPTIONS', () => {
|
||||
it('should handle CORS preflight requests', async () => {
|
||||
const response = await OPTIONS()
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(response.headers.get('Access-Control-Allow-Methods')).toBe('POST, OPTIONS')
|
||||
expect(response.headers.get('Access-Control-Allow-Headers')).toBe(
|
||||
'Content-Type, Authorization'
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -310,17 +310,3 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
export const OPTIONS = withRouteHandler(async () => {
|
||||
return NextResponse.json(
|
||||
{},
|
||||
{
|
||||
status: 200,
|
||||
headers: {
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
'Access-Control-Allow-Methods': 'POST, OPTIONS',
|
||||
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
|
||||
},
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
@@ -95,7 +95,7 @@ vi.mock('@/lib/uploads/setup.server', () => ({
|
||||
}))
|
||||
|
||||
import { uploadWorkspaceFile } from '@/lib/uploads/contexts/workspace'
|
||||
import { OPTIONS, POST } from '@/app/api/files/upload/route'
|
||||
import { POST } from '@/app/api/files/upload/route'
|
||||
|
||||
/**
|
||||
* Configure mocks for authenticated file upload tests
|
||||
@@ -307,14 +307,6 @@ describe('File Upload API Route', () => {
|
||||
expect(data).toHaveProperty('error')
|
||||
expect(typeof data.error).toBe('string')
|
||||
})
|
||||
|
||||
it('should handle CORS preflight requests', async () => {
|
||||
const response = await OPTIONS()
|
||||
|
||||
expect(response.status).toBe(204)
|
||||
expect(response.headers.get('Access-Control-Allow-Methods')).toBe('GET, POST, DELETE, OPTIONS')
|
||||
expect(response.headers.get('Access-Control-Allow-Headers')).toBe('Content-Type')
|
||||
})
|
||||
})
|
||||
|
||||
describe('File Upload Security Tests', () => {
|
||||
|
||||
@@ -21,11 +21,7 @@ import {
|
||||
validateFileType,
|
||||
} from '@/lib/uploads/utils/validation'
|
||||
import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
|
||||
import {
|
||||
createErrorResponse,
|
||||
createOptionsResponse,
|
||||
InvalidRequestError,
|
||||
} from '@/app/api/files/utils'
|
||||
import { createErrorResponse, InvalidRequestError } from '@/app/api/files/utils'
|
||||
|
||||
const ALLOWED_EXTENSIONS = new Set<string>(SUPPORTED_ATTACHMENT_EXTENSIONS)
|
||||
|
||||
@@ -430,7 +426,3 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
|
||||
return createErrorResponse(error instanceof Error ? error : new Error('File upload failed'))
|
||||
}
|
||||
})
|
||||
|
||||
export const OPTIONS = withRouteHandler(async () => {
|
||||
return createOptionsResponse()
|
||||
})
|
||||
|
||||
@@ -238,13 +238,3 @@ export function createErrorResponse(error: Error, status = 500): NextResponse {
|
||||
export function createSuccessResponse(data: ApiSuccessResponse): NextResponse {
|
||||
return NextResponse.json(data)
|
||||
}
|
||||
|
||||
export function createOptionsResponse(): NextResponse {
|
||||
return new NextResponse(null, {
|
||||
status: 204,
|
||||
headers: {
|
||||
'Access-Control-Allow-Methods': 'GET, POST, DELETE, OPTIONS',
|
||||
'Access-Control-Allow-Headers': 'Content-Type',
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -26,7 +26,6 @@ const {
|
||||
mockDbUpdate,
|
||||
mockSendEmail,
|
||||
mockRenderOTPEmail,
|
||||
mockAddCorsHeaders,
|
||||
mockSetFormAuthCookie,
|
||||
mockGetStorageMethod,
|
||||
mockZodParse,
|
||||
@@ -57,7 +56,6 @@ const {
|
||||
mockDbUpdate: vi.fn(),
|
||||
mockSendEmail: vi.fn(),
|
||||
mockRenderOTPEmail: vi.fn(),
|
||||
mockAddCorsHeaders: vi.fn(),
|
||||
mockSetFormAuthCookie: vi.fn(),
|
||||
mockGetStorageMethod: vi.fn(),
|
||||
mockZodParse: vi.fn(),
|
||||
@@ -119,7 +117,6 @@ vi.mock('@/components/emails', () => ({
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/core/security/deployment', () => ({
|
||||
addCorsHeaders: mockAddCorsHeaders,
|
||||
isEmailAllowed: (email: string, allowedEmails: string[]) => {
|
||||
if (allowedEmails.includes(email)) return true
|
||||
const atIndex = email.indexOf('@')
|
||||
@@ -253,7 +250,6 @@ describe('Form OTP API Route', () => {
|
||||
mockSendEmail.mockResolvedValue({ success: true })
|
||||
mockRenderOTPEmail.mockResolvedValue('<html>OTP Email</html>')
|
||||
|
||||
mockAddCorsHeaders.mockImplementation((response: unknown) => response)
|
||||
mockCreateSuccessResponse.mockImplementation((data: unknown) => ({
|
||||
json: () => Promise.resolve(data),
|
||||
status: 200,
|
||||
|
||||
@@ -7,7 +7,7 @@ import { renderOTPEmail } from '@/components/emails'
|
||||
import { requestFormEmailOtpContract, verifyFormEmailOtpContract } from '@/lib/api/contracts/forms'
|
||||
import { getValidationErrorMessage, parseRequest } from '@/lib/api/server'
|
||||
import { RateLimiter } from '@/lib/core/rate-limiter'
|
||||
import { addCorsHeaders, isEmailAllowed } from '@/lib/core/security/deployment'
|
||||
import { isEmailAllowed } from '@/lib/core/security/deployment'
|
||||
import {
|
||||
decodeOTPValue,
|
||||
deleteOTP,
|
||||
@@ -47,15 +47,12 @@ export const POST = withRouteHandler(
|
||||
)
|
||||
const response = createErrorResponse('Too many requests. Please try again later.', 429)
|
||||
response.headers.set('Retry-After', String(retryAfter))
|
||||
return addCorsHeaders(response, request)
|
||||
return response
|
||||
}
|
||||
|
||||
const parsed = await parseRequest(requestFormEmailOtpContract, request, context, {
|
||||
validationErrorResponse: (error) =>
|
||||
addCorsHeaders(
|
||||
createErrorResponse(getValidationErrorMessage(error, 'Invalid request'), 400),
|
||||
request
|
||||
),
|
||||
createErrorResponse(getValidationErrorMessage(error, 'Invalid request'), 400),
|
||||
})
|
||||
if (!parsed.success) return parsed.response
|
||||
const { email } = parsed.data.body
|
||||
@@ -74,23 +71,17 @@ export const POST = withRouteHandler(
|
||||
|
||||
if (deploymentResult.length === 0) {
|
||||
logger.warn(`[${requestId}] Form not found for identifier: ${identifier}`)
|
||||
return addCorsHeaders(createErrorResponse('Form not found', 404), request)
|
||||
return createErrorResponse('Form not found', 404)
|
||||
}
|
||||
|
||||
const deployment = deploymentResult[0]
|
||||
|
||||
if (!deployment.isActive) {
|
||||
return addCorsHeaders(
|
||||
createErrorResponse('This form is currently unavailable', 403),
|
||||
request
|
||||
)
|
||||
return createErrorResponse('This form is currently unavailable', 403)
|
||||
}
|
||||
|
||||
if (deployment.authType !== 'email') {
|
||||
return addCorsHeaders(
|
||||
createErrorResponse('This form does not use email authentication', 400),
|
||||
request
|
||||
)
|
||||
return createErrorResponse('This form does not use email authentication', 400)
|
||||
}
|
||||
|
||||
const allowedEmails: string[] = Array.isArray(deployment.allowedEmails)
|
||||
@@ -98,10 +89,7 @@ export const POST = withRouteHandler(
|
||||
: []
|
||||
|
||||
if (!isEmailAllowed(email, allowedEmails)) {
|
||||
return addCorsHeaders(
|
||||
createErrorResponse('Email not authorized for this form', 403),
|
||||
request
|
||||
)
|
||||
return createErrorResponse('Email not authorized for this form', 403)
|
||||
}
|
||||
|
||||
const emailRateLimit = await rateLimiter.checkRateLimitDirect(
|
||||
@@ -120,7 +108,7 @@ export const POST = withRouteHandler(
|
||||
429
|
||||
)
|
||||
response.headers.set('Retry-After', String(retryAfter))
|
||||
return addCorsHeaders(response, request)
|
||||
return response
|
||||
}
|
||||
|
||||
const otp = generateOTP()
|
||||
@@ -141,17 +129,14 @@ export const POST = withRouteHandler(
|
||||
|
||||
if (!emailResult.success) {
|
||||
logger.error(`[${requestId}] Failed to send OTP email:`, emailResult.message)
|
||||
return addCorsHeaders(
|
||||
createErrorResponse('Failed to send verification email', 500),
|
||||
request
|
||||
)
|
||||
return createErrorResponse('Failed to send verification email', 500)
|
||||
}
|
||||
|
||||
logger.info(`[${requestId}] OTP sent to ${email} for form ${deployment.id}`)
|
||||
return addCorsHeaders(createSuccessResponse({ message: 'Verification code sent' }), request)
|
||||
return createSuccessResponse({ message: 'Verification code sent' })
|
||||
} catch (error) {
|
||||
logger.error(`[${requestId}] Error processing OTP request:`, error)
|
||||
return addCorsHeaders(createErrorResponse('Failed to process request', 500), request)
|
||||
return createErrorResponse('Failed to process request', 500)
|
||||
}
|
||||
}
|
||||
)
|
||||
@@ -164,10 +149,7 @@ export const PUT = withRouteHandler(
|
||||
try {
|
||||
const parsed = await parseRequest(verifyFormEmailOtpContract, request, context, {
|
||||
validationErrorResponse: (error) =>
|
||||
addCorsHeaders(
|
||||
createErrorResponse(getValidationErrorMessage(error, 'Invalid request'), 400),
|
||||
request
|
||||
),
|
||||
createErrorResponse(getValidationErrorMessage(error, 'Invalid request'), 400),
|
||||
})
|
||||
if (!parsed.success) return parsed.response
|
||||
const { email, otp } = parsed.data.body
|
||||
@@ -186,23 +168,17 @@ export const PUT = withRouteHandler(
|
||||
|
||||
if (deploymentResult.length === 0) {
|
||||
logger.warn(`[${requestId}] Form not found for identifier: ${identifier}`)
|
||||
return addCorsHeaders(createErrorResponse('Form not found', 404), request)
|
||||
return createErrorResponse('Form not found', 404)
|
||||
}
|
||||
|
||||
const deployment = deploymentResult[0]
|
||||
|
||||
if (!deployment.isActive) {
|
||||
return addCorsHeaders(
|
||||
createErrorResponse('This form is currently unavailable', 403),
|
||||
request
|
||||
)
|
||||
return createErrorResponse('This form is currently unavailable', 403)
|
||||
}
|
||||
|
||||
if (deployment.authType !== 'email') {
|
||||
return addCorsHeaders(
|
||||
createErrorResponse('This form does not use email authentication', 400),
|
||||
request
|
||||
)
|
||||
return createErrorResponse('This form does not use email authentication', 400)
|
||||
}
|
||||
|
||||
const allowedEmails: string[] = Array.isArray(deployment.allowedEmails)
|
||||
@@ -210,18 +186,12 @@ export const PUT = withRouteHandler(
|
||||
: []
|
||||
|
||||
if (!isEmailAllowed(email, allowedEmails)) {
|
||||
return addCorsHeaders(
|
||||
createErrorResponse('Email not authorized for this form', 403),
|
||||
request
|
||||
)
|
||||
return createErrorResponse('Email not authorized for this form', 403)
|
||||
}
|
||||
|
||||
const storedValue = await getOTP('form', deployment.id, email)
|
||||
if (!storedValue) {
|
||||
return addCorsHeaders(
|
||||
createErrorResponse('No verification code found, request a new one', 400),
|
||||
request
|
||||
)
|
||||
return createErrorResponse('No verification code found, request a new one', 400)
|
||||
}
|
||||
|
||||
const { otp: storedOTP, attempts } = decodeOTPValue(storedValue)
|
||||
@@ -229,33 +199,27 @@ export const PUT = withRouteHandler(
|
||||
if (attempts >= MAX_OTP_ATTEMPTS) {
|
||||
await deleteOTP('form', deployment.id, email)
|
||||
logger.warn(`[${requestId}] OTP already locked out for ${email}`)
|
||||
return addCorsHeaders(
|
||||
createErrorResponse('Too many failed attempts. Please request a new code.', 429),
|
||||
request
|
||||
)
|
||||
return createErrorResponse('Too many failed attempts. Please request a new code.', 429)
|
||||
}
|
||||
|
||||
if (storedOTP !== otp) {
|
||||
const result = await incrementOTPAttempts('form', deployment.id, email, storedValue)
|
||||
if (result === 'locked') {
|
||||
logger.warn(`[${requestId}] OTP invalidated after max failed attempts for ${email}`)
|
||||
return addCorsHeaders(
|
||||
createErrorResponse('Too many failed attempts. Please request a new code.', 429),
|
||||
request
|
||||
)
|
||||
return createErrorResponse('Too many failed attempts. Please request a new code.', 429)
|
||||
}
|
||||
return addCorsHeaders(createErrorResponse('Invalid verification code', 400), request)
|
||||
return createErrorResponse('Invalid verification code', 400)
|
||||
}
|
||||
|
||||
await deleteOTP('form', deployment.id, email)
|
||||
|
||||
const response = addCorsHeaders(createSuccessResponse({ authenticated: true }), request)
|
||||
const response = createSuccessResponse({ authenticated: true })
|
||||
setFormAuthCookie(response, deployment.id, deployment.authType, deployment.password)
|
||||
|
||||
return response
|
||||
} catch (error) {
|
||||
logger.error(`[${requestId}] Error verifying OTP:`, error)
|
||||
return addCorsHeaders(createErrorResponse('Failed to process request', 500), request)
|
||||
return createErrorResponse('Failed to process request', 500)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
@@ -6,7 +6,7 @@ import { and, eq, isNull } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { formSubmitBodySchema } from '@/lib/api/contracts/forms'
|
||||
import { parseJsonBody } from '@/lib/api/server'
|
||||
import { addCorsHeaders, validateAuthToken } from '@/lib/core/security/deployment'
|
||||
import { validateAuthToken } from '@/lib/core/security/deployment'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
import { preprocessExecution } from '@/lib/execution/preprocessing'
|
||||
@@ -55,7 +55,7 @@ export const POST = withRouteHandler(
|
||||
try {
|
||||
const parsedJson = await parseJsonBody(request)
|
||||
if (!parsedJson.success) {
|
||||
return addCorsHeaders(createErrorResponse('Invalid request body', 400), request)
|
||||
return createErrorResponse('Invalid request body', 400)
|
||||
}
|
||||
|
||||
const bodyValidation = formSubmitBodySchema.safeParse(parsedJson.data)
|
||||
@@ -64,10 +64,7 @@ export const POST = withRouteHandler(
|
||||
.map((err) => `${err.path.join('.')}: ${err.message}`)
|
||||
.join(', ')
|
||||
logger.warn(`[${requestId}] Validation error: ${errorMessage}`)
|
||||
return addCorsHeaders(
|
||||
createErrorResponse(`Invalid request body: ${errorMessage}`, 400),
|
||||
request
|
||||
)
|
||||
return createErrorResponse(`Invalid request body: ${errorMessage}`, 400)
|
||||
}
|
||||
|
||||
const parsedBody = bodyValidation.data
|
||||
@@ -89,7 +86,7 @@ export const POST = withRouteHandler(
|
||||
|
||||
if (deploymentResult.length === 0) {
|
||||
logger.warn(`[${requestId}] Form not found for identifier: ${identifier}`)
|
||||
return addCorsHeaders(createErrorResponse('Form not found', 404), request)
|
||||
return createErrorResponse('Form not found', 404)
|
||||
}
|
||||
|
||||
const deployment = deploymentResult[0]
|
||||
@@ -108,10 +105,7 @@ export const POST = withRouteHandler(
|
||||
logger.warn(
|
||||
`[${requestId}] Cannot log: workflow ${deployment.workflowId} has no workspace`
|
||||
)
|
||||
return addCorsHeaders(
|
||||
createErrorResponse('This form is currently unavailable', 403),
|
||||
request
|
||||
)
|
||||
return createErrorResponse('This form is currently unavailable', 403)
|
||||
}
|
||||
|
||||
const executionId = generateId()
|
||||
@@ -136,31 +130,25 @@ export const POST = withRouteHandler(
|
||||
traceSpans: [],
|
||||
})
|
||||
|
||||
return addCorsHeaders(
|
||||
createErrorResponse('This form is currently unavailable', 403),
|
||||
request
|
||||
)
|
||||
return createErrorResponse('This form is currently unavailable', 403)
|
||||
}
|
||||
|
||||
const authResult = await validateFormAuth(requestId, deployment, request, parsedBody)
|
||||
if (!authResult.authorized) {
|
||||
return addCorsHeaders(
|
||||
createErrorResponse(authResult.error || 'Authentication required', 401),
|
||||
request
|
||||
)
|
||||
return createErrorResponse(authResult.error || 'Authentication required', 401)
|
||||
}
|
||||
|
||||
const { formData, password, email } = parsedBody
|
||||
|
||||
// If only authentication credentials provided (no form data), just return authenticated
|
||||
if ((password || email) && !formData) {
|
||||
const response = addCorsHeaders(createSuccessResponse({ authenticated: true }), request)
|
||||
const response = createSuccessResponse({ authenticated: true })
|
||||
setFormAuthCookie(response, deployment.id, deployment.authType, deployment.password)
|
||||
return response
|
||||
}
|
||||
|
||||
if (!formData || Object.keys(formData).length === 0) {
|
||||
return addCorsHeaders(createErrorResponse('No form data provided', 400), request)
|
||||
return createErrorResponse('No form data provided', 400)
|
||||
}
|
||||
|
||||
const executionId = generateId()
|
||||
@@ -184,12 +172,9 @@ export const POST = withRouteHandler(
|
||||
|
||||
if (!preprocessResult.success) {
|
||||
logger.warn(`[${requestId}] Preprocessing failed: ${preprocessResult.error?.message}`)
|
||||
return addCorsHeaders(
|
||||
createErrorResponse(
|
||||
preprocessResult.error?.message || 'Failed to process request',
|
||||
preprocessResult.error?.statusCode || 500
|
||||
),
|
||||
request
|
||||
return createErrorResponse(
|
||||
preprocessResult.error?.message || 'Failed to process request',
|
||||
preprocessResult.error?.statusCode || 500
|
||||
)
|
||||
}
|
||||
|
||||
@@ -198,10 +183,7 @@ export const POST = withRouteHandler(
|
||||
const workspaceId = workflowRecord?.workspaceId
|
||||
if (!workspaceId) {
|
||||
logger.error(`[${requestId}] Workflow ${deployment.workflowId} has no workspaceId`)
|
||||
return addCorsHeaders(
|
||||
createErrorResponse('Workflow has no associated workspace', 500),
|
||||
request
|
||||
)
|
||||
return createErrorResponse('Workflow has no associated workspace', 500)
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -264,29 +246,20 @@ export const POST = withRouteHandler(
|
||||
|
||||
// Return success with customizations for thank you screen
|
||||
const customizations = deployment.customizations as Record<string, any> | null
|
||||
return addCorsHeaders(
|
||||
createSuccessResponse({
|
||||
success: true,
|
||||
executionId,
|
||||
thankYouTitle: customizations?.thankYouTitle || 'Thank you!',
|
||||
thankYouMessage:
|
||||
customizations?.thankYouMessage || 'Your response has been submitted successfully.',
|
||||
}),
|
||||
request
|
||||
)
|
||||
return createSuccessResponse({
|
||||
success: true,
|
||||
executionId,
|
||||
thankYouTitle: customizations?.thankYouTitle || 'Thank you!',
|
||||
thankYouMessage:
|
||||
customizations?.thankYouMessage || 'Your response has been submitted successfully.',
|
||||
})
|
||||
} catch (error: any) {
|
||||
logger.error(`[${requestId}] Error processing form submission:`, error)
|
||||
return addCorsHeaders(
|
||||
createErrorResponse(error.message || 'Failed to process form submission', 500),
|
||||
request
|
||||
)
|
||||
return createErrorResponse(error.message || 'Failed to process form submission', 500)
|
||||
}
|
||||
} catch (error: any) {
|
||||
logger.error(`[${requestId}] Error processing form submission:`, error)
|
||||
return addCorsHeaders(
|
||||
createErrorResponse(error.message || 'Failed to process form submission', 500),
|
||||
request
|
||||
)
|
||||
return createErrorResponse(error.message || 'Failed to process form submission', 500)
|
||||
}
|
||||
}
|
||||
)
|
||||
@@ -316,17 +289,14 @@ export const GET = withRouteHandler(
|
||||
|
||||
if (deploymentResult.length === 0) {
|
||||
logger.warn(`[${requestId}] Form not found for identifier: ${identifier}`)
|
||||
return addCorsHeaders(createErrorResponse('Form not found', 404), request)
|
||||
return createErrorResponse('Form not found', 404)
|
||||
}
|
||||
|
||||
const deployment = deploymentResult[0]
|
||||
|
||||
if (!deployment.isActive) {
|
||||
logger.warn(`[${requestId}] Form is not active: ${identifier}`)
|
||||
return addCorsHeaders(
|
||||
createErrorResponse('This form is currently unavailable', 403),
|
||||
request
|
||||
)
|
||||
return createErrorResponse('This form is currently unavailable', 403)
|
||||
}
|
||||
|
||||
// Get the workflow's input schema
|
||||
@@ -341,18 +311,15 @@ export const GET = withRouteHandler(
|
||||
authCookie &&
|
||||
validateAuthToken(authCookie.value, deployment.id, deployment.password)
|
||||
) {
|
||||
return addCorsHeaders(
|
||||
createSuccessResponse({
|
||||
id: deployment.id,
|
||||
title: deployment.title,
|
||||
description: deployment.description,
|
||||
customizations: deployment.customizations,
|
||||
authType: deployment.authType,
|
||||
showBranding: deployment.showBranding,
|
||||
inputSchema,
|
||||
}),
|
||||
request
|
||||
)
|
||||
return createSuccessResponse({
|
||||
id: deployment.id,
|
||||
title: deployment.title,
|
||||
description: deployment.description,
|
||||
customizations: deployment.customizations,
|
||||
authType: deployment.authType,
|
||||
showBranding: deployment.showBranding,
|
||||
inputSchema,
|
||||
})
|
||||
}
|
||||
|
||||
// Check authentication requirement
|
||||
@@ -362,46 +329,33 @@ export const GET = withRouteHandler(
|
||||
logger.info(
|
||||
`[${requestId}] Authentication required for form: ${identifier}, type: ${deployment.authType}`
|
||||
)
|
||||
return addCorsHeaders(
|
||||
NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: authResult.error || 'Authentication required',
|
||||
authType: deployment.authType,
|
||||
title: deployment.title,
|
||||
customizations: {
|
||||
primaryColor: (deployment.customizations as any)?.primaryColor,
|
||||
logoUrl: (deployment.customizations as any)?.logoUrl,
|
||||
},
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: authResult.error || 'Authentication required',
|
||||
authType: deployment.authType,
|
||||
title: deployment.title,
|
||||
customizations: {
|
||||
primaryColor: (deployment.customizations as any)?.primaryColor,
|
||||
logoUrl: (deployment.customizations as any)?.logoUrl,
|
||||
},
|
||||
{ status: 401 }
|
||||
),
|
||||
request
|
||||
},
|
||||
{ status: 401 }
|
||||
)
|
||||
}
|
||||
|
||||
return addCorsHeaders(
|
||||
createSuccessResponse({
|
||||
id: deployment.id,
|
||||
title: deployment.title,
|
||||
description: deployment.description,
|
||||
customizations: deployment.customizations,
|
||||
authType: deployment.authType,
|
||||
showBranding: deployment.showBranding,
|
||||
inputSchema,
|
||||
}),
|
||||
request
|
||||
)
|
||||
return createSuccessResponse({
|
||||
id: deployment.id,
|
||||
title: deployment.title,
|
||||
description: deployment.description,
|
||||
customizations: deployment.customizations,
|
||||
authType: deployment.authType,
|
||||
showBranding: deployment.showBranding,
|
||||
inputSchema,
|
||||
})
|
||||
} catch (error: any) {
|
||||
logger.error(`[${requestId}] Error fetching form info:`, error)
|
||||
return addCorsHeaders(
|
||||
createErrorResponse(error.message || 'Failed to fetch form information', 500),
|
||||
request
|
||||
)
|
||||
return createErrorResponse(error.message || 'Failed to fetch form information', 500)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
export const OPTIONS = withRouteHandler(async (request: NextRequest) => {
|
||||
return addCorsHeaders(new NextResponse(null, { status: 204 }), request)
|
||||
})
|
||||
|
||||
@@ -7,17 +7,13 @@ import { encryptionMock, encryptionMockFns, workflowsUtilsMock } from '@sim/test
|
||||
import type { NextResponse } from 'next/server'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const {
|
||||
mockValidateAuthToken,
|
||||
mockSetDeploymentAuthCookie,
|
||||
mockAddCorsHeaders,
|
||||
mockIsEmailAllowed,
|
||||
} = vi.hoisted(() => ({
|
||||
mockValidateAuthToken: vi.fn().mockReturnValue(false),
|
||||
mockSetDeploymentAuthCookie: vi.fn(),
|
||||
mockAddCorsHeaders: vi.fn((response: unknown) => response),
|
||||
mockIsEmailAllowed: vi.fn(),
|
||||
}))
|
||||
const { mockValidateAuthToken, mockSetDeploymentAuthCookie, mockIsEmailAllowed } = vi.hoisted(
|
||||
() => ({
|
||||
mockValidateAuthToken: vi.fn().mockReturnValue(false),
|
||||
mockSetDeploymentAuthCookie: vi.fn(),
|
||||
mockIsEmailAllowed: vi.fn(),
|
||||
})
|
||||
)
|
||||
|
||||
const mockDecryptSecret = encryptionMockFns.mockDecryptSecret
|
||||
|
||||
@@ -26,7 +22,6 @@ vi.mock('@/lib/core/security/encryption', () => encryptionMock)
|
||||
vi.mock('@/lib/core/security/deployment', () => ({
|
||||
validateAuthToken: mockValidateAuthToken,
|
||||
setDeploymentAuthCookie: mockSetDeploymentAuthCookie,
|
||||
addCorsHeaders: mockAddCorsHeaders,
|
||||
isEmailAllowed: mockIsEmailAllowed,
|
||||
}))
|
||||
|
||||
|
||||
@@ -386,19 +386,6 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
|
||||
}
|
||||
})
|
||||
|
||||
export const OPTIONS = withRouteHandler(async () => {
|
||||
return new NextResponse(null, {
|
||||
status: 204,
|
||||
headers: {
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
'Access-Control-Allow-Methods': 'GET, POST, OPTIONS, DELETE',
|
||||
'Access-Control-Allow-Headers':
|
||||
'Content-Type, Authorization, X-API-Key, X-Requested-With, Accept',
|
||||
'Access-Control-Max-Age': '86400',
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
export const DELETE = withRouteHandler(async (request: NextRequest) => {
|
||||
void request
|
||||
return NextResponse.json(createError(0, -32000, 'Method not allowed.'), { status: 405 })
|
||||
|
||||
@@ -131,21 +131,3 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
// Add a helpful OPTIONS handler for CORS preflight
|
||||
export const OPTIONS = withRouteHandler(async (request: NextRequest) => {
|
||||
const requestId = generateRequestId()
|
||||
const queryValidation = noInputSchema.safeParse(
|
||||
Object.fromEntries(request.nextUrl.searchParams.entries())
|
||||
)
|
||||
if (!queryValidation.success) return validationErrorResponse(queryValidation.error)
|
||||
logger.info(`[${requestId}] OPTIONS request received for /api/templates/approved/sanitized`)
|
||||
|
||||
return new NextResponse(null, {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Access-Control-Allow-Methods': 'GET, OPTIONS',
|
||||
'Access-Control-Allow-Headers': 'X-API-Key, Content-Type',
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
@@ -98,15 +98,3 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
export const OPTIONS = withRouteHandler(async () => {
|
||||
return new NextResponse(null, {
|
||||
status: 204,
|
||||
headers: {
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
'Access-Control-Allow-Methods': 'GET, OPTIONS',
|
||||
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
|
||||
'Access-Control-Max-Age': '86400',
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
import { safeCompare } from '@sim/security/compare'
|
||||
import { sha256Hex } from '@sim/security/hash'
|
||||
import { hmacSha256Hex } from '@sim/security/hmac'
|
||||
import type { NextRequest, NextResponse } from 'next/server'
|
||||
import type { NextResponse } from 'next/server'
|
||||
import { env } from '@/lib/core/config/env'
|
||||
import { isDev } from '@/lib/core/config/feature-flags'
|
||||
|
||||
/**
|
||||
* Shared authentication utilities for deployed chat and form endpoints.
|
||||
* These functions handle token generation, validation, cookies, and CORS.
|
||||
* Handles token generation, validation, and auth cookies. CORS for these
|
||||
* endpoints lives in proxy.ts as the single source of truth.
|
||||
*/
|
||||
|
||||
function signPayload(payload: string): string {
|
||||
@@ -93,24 +94,6 @@ export function setDeploymentAuthCookie(
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds CORS headers to allow cross-origin requests for embedded deployments.
|
||||
* We reflect the requesting origin to support same-site cross-origin setups
|
||||
* (e.g. subdomains), but never set Allow-Credentials — auth cookies use
|
||||
* SameSite=Lax and are handled within same-origin iframe contexts.
|
||||
*/
|
||||
export function addCorsHeaders(response: NextResponse, request: NextRequest): NextResponse {
|
||||
const origin = request.headers.get('origin')
|
||||
|
||||
if (origin) {
|
||||
response.headers.set('Access-Control-Allow-Origin', origin)
|
||||
response.headers.set('Access-Control-Allow-Methods', 'GET, POST, OPTIONS')
|
||||
response.headers.set('Access-Control-Allow-Headers', 'Content-Type, X-Requested-With')
|
||||
}
|
||||
|
||||
return response
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if an email matches the allowed emails list (exact match or domain match)
|
||||
*/
|
||||
|
||||
+1
-86
@@ -156,85 +156,10 @@ const nextConfig: NextConfig = {
|
||||
{ key: 'Access-Control-Allow-Headers', value: 'Content-Type, Accept' },
|
||||
],
|
||||
},
|
||||
{
|
||||
// API routes CORS headers
|
||||
source: '/api/:path*',
|
||||
headers: [
|
||||
{ key: 'Access-Control-Allow-Credentials', value: 'true' },
|
||||
{
|
||||
key: 'Access-Control-Allow-Origin',
|
||||
value: env.NEXT_PUBLIC_APP_URL || 'http://localhost:3001',
|
||||
},
|
||||
{
|
||||
key: 'Access-Control-Allow-Methods',
|
||||
value: 'GET,POST,OPTIONS,PUT,DELETE',
|
||||
},
|
||||
{
|
||||
key: 'Access-Control-Allow-Headers',
|
||||
value:
|
||||
'X-CSRF-Token, X-Requested-With, Accept, Accept-Version, Content-Length, Content-MD5, Content-Type, Date, X-Api-Version, X-API-Key, Authorization',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
source: '/api/auth/oauth2/:path*',
|
||||
headers: [
|
||||
{ key: 'Access-Control-Allow-Credentials', value: 'false' },
|
||||
{ key: 'Access-Control-Allow-Origin', value: '*' },
|
||||
{ key: 'Access-Control-Allow-Methods', value: 'GET, POST, OPTIONS' },
|
||||
{
|
||||
key: 'Access-Control-Allow-Headers',
|
||||
value: 'Content-Type, Authorization, Accept',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
source: '/api/auth/jwks',
|
||||
headers: [
|
||||
{ key: 'Access-Control-Allow-Credentials', value: 'false' },
|
||||
{ key: 'Access-Control-Allow-Origin', value: '*' },
|
||||
{ key: 'Access-Control-Allow-Methods', value: 'GET, OPTIONS' },
|
||||
{ key: 'Access-Control-Allow-Headers', value: 'Content-Type, Accept' },
|
||||
],
|
||||
},
|
||||
{
|
||||
source: '/api/auth/.well-known/:path*',
|
||||
headers: [
|
||||
{ key: 'Access-Control-Allow-Credentials', value: 'false' },
|
||||
{ key: 'Access-Control-Allow-Origin', value: '*' },
|
||||
{ key: 'Access-Control-Allow-Methods', value: 'GET, OPTIONS' },
|
||||
{ key: 'Access-Control-Allow-Headers', value: 'Content-Type, Accept' },
|
||||
],
|
||||
},
|
||||
{
|
||||
source: '/api/mcp/copilot',
|
||||
headers: [
|
||||
{ key: 'Access-Control-Allow-Credentials', value: 'false' },
|
||||
{ key: 'Access-Control-Allow-Origin', value: '*' },
|
||||
{
|
||||
key: 'Access-Control-Allow-Methods',
|
||||
value: 'GET, POST, OPTIONS, DELETE',
|
||||
},
|
||||
{
|
||||
key: 'Access-Control-Allow-Headers',
|
||||
value: 'Content-Type, Authorization, X-API-Key, X-Requested-With, Accept',
|
||||
},
|
||||
],
|
||||
},
|
||||
// For workflow execution API endpoints
|
||||
// /api/* CORS is set at runtime in proxy.ts (resolveApiCorsPolicy).
|
||||
{
|
||||
source: '/api/workflows/:id/execute',
|
||||
headers: [
|
||||
{ key: 'Access-Control-Allow-Origin', value: '*' },
|
||||
{
|
||||
key: 'Access-Control-Allow-Methods',
|
||||
value: 'GET,POST,OPTIONS,PUT',
|
||||
},
|
||||
{
|
||||
key: 'Access-Control-Allow-Headers',
|
||||
value:
|
||||
'X-CSRF-Token, X-Requested-With, Accept, Accept-Version, Content-Length, Content-MD5, Content-Type, Date, X-Api-Version, X-API-Key',
|
||||
},
|
||||
{ key: 'Cross-Origin-Embedder-Policy', value: 'unsafe-none' },
|
||||
{ key: 'Cross-Origin-Opener-Policy', value: 'unsafe-none' },
|
||||
{
|
||||
@@ -317,16 +242,6 @@ const nextConfig: NextConfig = {
|
||||
{ key: 'Cross-Origin-Opener-Policy', value: 'unsafe-none' },
|
||||
],
|
||||
},
|
||||
// Form API routes - allow cross-origin requests
|
||||
{
|
||||
source: '/api/form/:path*',
|
||||
headers: [
|
||||
{ key: 'Access-Control-Allow-Origin', value: '*' },
|
||||
{ key: 'Access-Control-Allow-Methods', value: 'GET, POST, OPTIONS' },
|
||||
{ key: 'Access-Control-Allow-Headers', value: 'Content-Type, X-Requested-With' },
|
||||
{ key: 'Access-Control-Allow-Credentials', value: 'true' },
|
||||
],
|
||||
},
|
||||
// Apply security headers to routes not handled by middleware runtime CSP
|
||||
// Middleware handles: /, /login, /signup, /workspace/*
|
||||
// Exclude chat and form routes which have their own permissive embed headers
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import { createEnvMock } from '@sim/testing'
|
||||
import type { NextRequest } from 'next/server'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
vi.mock('@/lib/core/config/env', () =>
|
||||
createEnvMock({ NEXT_PUBLIC_APP_URL: 'https://app.sim.test' })
|
||||
)
|
||||
|
||||
import { resolveApiCorsPolicy } from '@/proxy'
|
||||
|
||||
function makeRequest(pathname: string, origin?: string): NextRequest {
|
||||
return {
|
||||
nextUrl: { pathname },
|
||||
headers: {
|
||||
get: (name: string) => (name.toLowerCase() === 'origin' ? (origin ?? null) : null),
|
||||
},
|
||||
} as unknown as NextRequest
|
||||
}
|
||||
|
||||
describe('resolveApiCorsPolicy', () => {
|
||||
it('serves OAuth2 routes with wildcard origin and no credentials', () => {
|
||||
expect(resolveApiCorsPolicy(makeRequest('/api/auth/oauth2/token'))).toEqual({
|
||||
origin: '*',
|
||||
credentials: false,
|
||||
methods: 'GET, POST, OPTIONS',
|
||||
headers: 'Content-Type, Authorization, Accept',
|
||||
})
|
||||
})
|
||||
|
||||
it('serves JWKS and well-known with wildcard origin', () => {
|
||||
expect(resolveApiCorsPolicy(makeRequest('/api/auth/jwks')).origin).toBe('*')
|
||||
expect(
|
||||
resolveApiCorsPolicy(makeRequest('/api/auth/.well-known/openid-configuration')).origin
|
||||
).toBe('*')
|
||||
})
|
||||
|
||||
it('serves MCP copilot with DELETE in allowed methods', () => {
|
||||
const policy = resolveApiCorsPolicy(makeRequest('/api/mcp/copilot'))
|
||||
expect(policy.origin).toBe('*')
|
||||
expect(policy.methods).toContain('DELETE')
|
||||
expect(policy.headers).toContain('X-API-Key')
|
||||
})
|
||||
|
||||
it('reflects origin for chat and form embeds, never sets credentials', () => {
|
||||
const paths = [
|
||||
'/api/chat/abc',
|
||||
'/api/chat/abc/otp',
|
||||
'/api/chat/abc/sso',
|
||||
'/api/form/xyz',
|
||||
'/api/form/xyz/otp',
|
||||
]
|
||||
for (const path of paths) {
|
||||
const policy = resolveApiCorsPolicy(makeRequest(path, 'https://customer.example'))
|
||||
expect(policy).toEqual({
|
||||
origin: 'https://customer.example',
|
||||
credentials: false,
|
||||
methods: 'GET, POST, PUT, OPTIONS',
|
||||
headers: 'Content-Type, X-Requested-With',
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
it('allows PUT on the embed policy (used by OTP verification on /[identifier]/otp)', () => {
|
||||
const policy = resolveApiCorsPolicy(
|
||||
makeRequest('/api/chat/abc/otp', 'https://customer.example')
|
||||
)
|
||||
expect(policy.methods).toContain('PUT')
|
||||
})
|
||||
|
||||
it('falls back to wildcard for chat/form embeds when no origin header is present', () => {
|
||||
expect(resolveApiCorsPolicy(makeRequest('/api/chat/abc')).origin).toBe('*')
|
||||
})
|
||||
|
||||
it('applies the embed policy to future identifier subroutes (not just /otp, /sso)', () => {
|
||||
const policy = resolveApiCorsPolicy(
|
||||
makeRequest('/api/chat/abc/transcript', 'https://customer.example')
|
||||
)
|
||||
expect(policy.origin).toBe('https://customer.example')
|
||||
expect(policy.credentials).toBe(false)
|
||||
})
|
||||
|
||||
it('uses the default credentialed policy for workspace-internal chat/form routes', () => {
|
||||
const paths = [
|
||||
'/api/chat',
|
||||
'/api/chat/manage/abc',
|
||||
'/api/chat/validate',
|
||||
'/api/form',
|
||||
'/api/form/manage/abc',
|
||||
'/api/form/validate',
|
||||
]
|
||||
for (const path of paths) {
|
||||
const policy = resolveApiCorsPolicy(makeRequest(path, 'https://customer.example'))
|
||||
expect(policy.origin).toBe('https://app.sim.test')
|
||||
expect(policy.credentials).toBe(true)
|
||||
}
|
||||
})
|
||||
|
||||
it('serves workflow execute with wildcard origin and PUT method', () => {
|
||||
const policy = resolveApiCorsPolicy(
|
||||
makeRequest('/api/workflows/workflow-123/execute', 'https://other.example')
|
||||
)
|
||||
expect(policy.origin).toBe('*')
|
||||
expect(policy.credentials).toBe(false)
|
||||
expect(policy.methods).toContain('PUT')
|
||||
})
|
||||
|
||||
it('does not match the workflow execute rule for nested paths', () => {
|
||||
const policy = resolveApiCorsPolicy(
|
||||
makeRequest('/api/workflows/workflow-123/execute/extra', 'https://other.example')
|
||||
)
|
||||
expect(policy.origin).toBe('https://app.sim.test')
|
||||
})
|
||||
|
||||
it('returns default policy with APP_URL and credentials for other API routes', () => {
|
||||
const policy = resolveApiCorsPolicy(makeRequest('/api/files/upload'))
|
||||
expect(policy).toEqual({
|
||||
origin: 'https://app.sim.test',
|
||||
credentials: true,
|
||||
methods: 'GET,POST,OPTIONS,PUT,DELETE',
|
||||
headers: expect.stringContaining('Authorization'),
|
||||
})
|
||||
})
|
||||
|
||||
it('never pairs wildcard origin with credentials (CORS spec invariant)', () => {
|
||||
const paths = [
|
||||
'/api/auth/oauth2/token',
|
||||
'/api/auth/jwks',
|
||||
'/api/auth/.well-known/openid-configuration',
|
||||
'/api/mcp/copilot',
|
||||
'/api/chat/abc',
|
||||
'/api/form',
|
||||
'/api/workflows/wf/execute',
|
||||
'/api/files/upload',
|
||||
]
|
||||
for (const path of paths) {
|
||||
const policy = resolveApiCorsPolicy(makeRequest(path))
|
||||
if (policy.origin === '*') {
|
||||
expect(policy.credentials).toBe(false)
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -2,12 +2,149 @@ import { createLogger } from '@sim/logger'
|
||||
import { getSessionCookie } from 'better-auth/cookies'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { sendToProfound } from './lib/analytics/profound'
|
||||
import { getEnv } from './lib/core/config/env'
|
||||
import { isAuthDisabled, isHosted } from './lib/core/config/feature-flags'
|
||||
import { generateRuntimeCSP } from './lib/core/security/csp'
|
||||
import { getClientIp } from './lib/core/utils/request'
|
||||
|
||||
const logger = createLogger('Proxy')
|
||||
|
||||
export interface CorsPolicy {
|
||||
origin: string
|
||||
credentials: boolean
|
||||
methods: string
|
||||
headers: string
|
||||
}
|
||||
|
||||
const DEFAULT_API_ALLOWED_HEADERS =
|
||||
'X-CSRF-Token, X-Requested-With, Accept, Accept-Version, Content-Length, Content-MD5, Content-Type, Date, X-Api-Version, X-API-Key, Authorization'
|
||||
|
||||
const WORKFLOW_EXECUTE_HEADERS =
|
||||
'X-CSRF-Token, X-Requested-With, Accept, Accept-Version, Content-Length, Content-MD5, Content-Type, Date, X-Api-Version, X-API-Key'
|
||||
|
||||
/**
|
||||
* Workspace-internal segments under /api/{chat,form}/* that must NOT
|
||||
* receive the embed policy. They serve the workspace UI with session
|
||||
* cookies and need the default credentialed policy.
|
||||
*/
|
||||
const EMBED_RESERVED_SEGMENTS = new Set(['manage', 'validate'])
|
||||
|
||||
/**
|
||||
* True for /api/{chat,form}/[identifier] and any deeper subroute
|
||||
* (e.g. /otp, /sso). The identifier segment is explicitly checked
|
||||
* against EMBED_RESERVED_SEGMENTS so workspace-internal routes fall
|
||||
* through to the default credentialed policy.
|
||||
*/
|
||||
function isEmbedPath(pathname: string): boolean {
|
||||
const segments = pathname.split('/')
|
||||
if (segments.length < 4) return false
|
||||
if (segments[1] !== 'api') return false
|
||||
if (segments[2] !== 'chat' && segments[2] !== 'form') return false
|
||||
const identifier = segments[3]
|
||||
if (!identifier || EMBED_RESERVED_SEGMENTS.has(identifier)) return false
|
||||
return true
|
||||
}
|
||||
|
||||
interface CorsRule {
|
||||
match: (pathname: string) => boolean
|
||||
policy: (request: NextRequest) => CorsPolicy
|
||||
}
|
||||
|
||||
const CORS_RULES: readonly CorsRule[] = [
|
||||
{
|
||||
match: (p) => p.startsWith('/api/auth/oauth2/'),
|
||||
policy: () => ({
|
||||
origin: '*',
|
||||
credentials: false,
|
||||
methods: 'GET, POST, OPTIONS',
|
||||
headers: 'Content-Type, Authorization, Accept',
|
||||
}),
|
||||
},
|
||||
{
|
||||
match: (p) => p === '/api/auth/jwks' || p.startsWith('/api/auth/.well-known/'),
|
||||
policy: () => ({
|
||||
origin: '*',
|
||||
credentials: false,
|
||||
methods: 'GET, OPTIONS',
|
||||
headers: 'Content-Type, Accept',
|
||||
}),
|
||||
},
|
||||
{
|
||||
match: (p) => p === '/api/mcp/copilot',
|
||||
policy: () => ({
|
||||
origin: '*',
|
||||
credentials: false,
|
||||
methods: 'GET, POST, OPTIONS, DELETE',
|
||||
headers: 'Content-Type, Authorization, X-API-Key, X-Requested-With, Accept',
|
||||
}),
|
||||
},
|
||||
{
|
||||
// Embed endpoints: /api/chat/[identifier] and /api/form/[identifier]
|
||||
// (plus their /otp and /sso subroutes). These run on customer domains —
|
||||
// reflect the request origin and omit credentials (auth uses signed
|
||||
// tokens, not cookies). Workspace-internal subpaths (`manage`, `validate`,
|
||||
// and the bare collection routes) are deliberately excluded so they
|
||||
// continue to receive the default credentialed policy.
|
||||
match: (p) => isEmbedPath(p),
|
||||
policy: (request) => ({
|
||||
origin: request.headers.get('origin') || '*',
|
||||
credentials: false,
|
||||
// PUT is required for OTP verification on /[identifier]/otp.
|
||||
methods: 'GET, POST, PUT, OPTIONS',
|
||||
headers: 'Content-Type, X-Requested-With',
|
||||
}),
|
||||
},
|
||||
{
|
||||
match: (p) => /^\/api\/workflows\/[^/]+\/execute$/.test(p),
|
||||
policy: () => ({
|
||||
origin: '*',
|
||||
credentials: false,
|
||||
methods: 'GET,POST,OPTIONS,PUT',
|
||||
headers: WORKFLOW_EXECUTE_HEADERS,
|
||||
}),
|
||||
},
|
||||
]
|
||||
|
||||
/**
|
||||
* Single source of truth for CORS on /api/* — next.config.ts headers are
|
||||
* baked at build time and would freeze NEXT_PUBLIC_APP_URL into the image.
|
||||
*/
|
||||
export function resolveApiCorsPolicy(request: NextRequest): CorsPolicy {
|
||||
const { pathname } = request.nextUrl
|
||||
for (const rule of CORS_RULES) {
|
||||
if (rule.match(pathname)) return rule.policy(request)
|
||||
}
|
||||
return {
|
||||
origin: getEnv('NEXT_PUBLIC_APP_URL') || 'http://localhost:3001',
|
||||
credentials: true,
|
||||
methods: 'GET,POST,OPTIONS,PUT,DELETE',
|
||||
headers: DEFAULT_API_ALLOWED_HEADERS,
|
||||
}
|
||||
}
|
||||
|
||||
const CORS_PREFLIGHT_MAX_AGE = '86400'
|
||||
|
||||
function applyCorsHeaders(response: NextResponse, policy: CorsPolicy): void {
|
||||
response.headers.set('Access-Control-Allow-Origin', policy.origin)
|
||||
response.headers.set('Access-Control-Allow-Credentials', String(policy.credentials))
|
||||
response.headers.set('Access-Control-Allow-Methods', policy.methods)
|
||||
response.headers.set('Access-Control-Allow-Headers', policy.headers)
|
||||
if (policy.origin !== '*') {
|
||||
response.headers.set('Vary', 'Origin')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Short-circuit preflight: Next's auto-OPTIONS for route handlers without
|
||||
* an explicit OPTIONS export does not carry middleware headers.
|
||||
*/
|
||||
function buildPreflightResponse(policy: CorsPolicy): NextResponse {
|
||||
const response = new NextResponse(null, { status: 204 })
|
||||
applyCorsHeaders(response, policy)
|
||||
response.headers.set('Access-Control-Max-Age', CORS_PREFLIGHT_MAX_AGE)
|
||||
return response
|
||||
}
|
||||
|
||||
const SUSPICIOUS_UA_PATTERNS = [
|
||||
/^\s*$/, // Empty user agents
|
||||
/\.\./, // Path traversal attempt
|
||||
@@ -122,6 +259,16 @@ function handleSecurityFiltering(request: NextRequest): NextResponse | null {
|
||||
export async function proxy(request: NextRequest) {
|
||||
const url = request.nextUrl
|
||||
|
||||
if (url.pathname.startsWith('/api/')) {
|
||||
const policy = resolveApiCorsPolicy(request)
|
||||
if (request.method === 'OPTIONS') {
|
||||
return buildPreflightResponse(policy)
|
||||
}
|
||||
const response = NextResponse.next()
|
||||
applyCorsHeaders(response, policy)
|
||||
return response
|
||||
}
|
||||
|
||||
const sessionCookie = getSessionCookie(request)
|
||||
const hasActiveSession = isAuthDisabled || !!sessionCookie
|
||||
|
||||
@@ -202,6 +349,7 @@ export const config = {
|
||||
'/login',
|
||||
'/signup',
|
||||
'/invite/:path*', // Match invitation routes
|
||||
'/api/:path*', // Runtime CORS
|
||||
// Catch-all for other pages, excluding static assets and public directories
|
||||
'/((?!api/|api$|_next/static|_next/image|ingest|favicon.ico|logo/|static/|footer/|social/|enterprise/|favicon/|twitter/|robots.txt|sitemap.xml).*)',
|
||||
],
|
||||
|
||||
@@ -74,11 +74,6 @@ ENV NEXT_TELEMETRY_DISABLED=1 \
|
||||
ARG DATABASE_URL="postgresql://user:pass@localhost:5432/dummy"
|
||||
ENV DATABASE_URL=${DATABASE_URL}
|
||||
|
||||
# Provide dummy NEXT_PUBLIC_APP_URL for build-time evaluation
|
||||
# Runtime environments should override this with the actual URL
|
||||
ARG NEXT_PUBLIC_APP_URL="http://localhost:3000"
|
||||
ENV NEXT_PUBLIC_APP_URL=${NEXT_PUBLIC_APP_URL}
|
||||
|
||||
# Per-platform cache id keeps arm64/amd64 SWC artifacts isolated.
|
||||
RUN --mount=type=cache,id=next-cache-${TARGETPLATFORM},target=/app/apps/sim/.next/cache \
|
||||
--mount=type=cache,id=turbo-cache-${TARGETPLATFORM},target=/app/.turbo \
|
||||
|
||||
Reference in New Issue
Block a user