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:
Waleed
2026-05-19 12:04:23 -07:00
committed by GitHub
co-authored by Claude Opus 4.7
parent 3930485dff
commit ef14b2b580
26 changed files with 441 additions and 550 deletions
@@ -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,
+25 -52
View File
@@ -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) => {
+22 -59
View File
@@ -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 })
}
)
-3
View File
@@ -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,
}))
+1 -9
View File
@@ -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')
})
})
-8
View File
@@ -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',
},
}
)
})
+1 -13
View File
@@ -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'
)
})
})
})
-14
View File
@@ -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',
},
}
)
})
+1 -9
View File
@@ -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', () => {
+1 -9
View File
@@ -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()
})
-10
View File
@@ -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,
+22 -58
View File
@@ -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)
}
}
)
+54 -100
View File
@@ -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 -12
View File
@@ -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,
}))
-13
View File
@@ -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',
},
})
})
-12
View File
@@ -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',
},
})
})
+3 -20
View File
@@ -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
View File
@@ -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
+145
View File
@@ -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)
}
}
})
})
+148
View File
@@ -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).*)',
],
-5
View File
@@ -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 \