From e168fab8ed930d4e1e325629b57a57ff555d3075 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Mon, 18 May 2026 19:39:20 -0700 Subject: [PATCH 1/6] 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 --- apps/sim/app/api/files/delete/route.test.ts | 10 +- apps/sim/app/api/files/delete/route.ts | 8 -- .../app/api/files/presigned/batch/route.ts | 14 --- .../sim/app/api/files/presigned/route.test.ts | 14 +-- apps/sim/app/api/files/presigned/route.ts | 14 --- apps/sim/app/api/files/upload/route.test.ts | 10 +- apps/sim/app/api/files/upload/route.ts | 10 +- apps/sim/app/api/files/utils.ts | 10 -- apps/sim/app/api/form/[identifier]/route.ts | 4 - apps/sim/app/api/mcp/copilot/route.ts | 13 --- .../api/templates/approved/sanitized/route.ts | 18 --- apps/sim/app/api/tools/image/route.ts | 12 -- apps/sim/lib/core/security/deployment.ts | 1 + apps/sim/next.config.ts | 87 +------------- apps/sim/proxy.ts | 107 ++++++++++++++++++ docker/app.Dockerfile | 5 - 16 files changed, 113 insertions(+), 224 deletions(-) diff --git a/apps/sim/app/api/files/delete/route.test.ts b/apps/sim/app/api/files/delete/route.test.ts index df3bad6170f..eed07748581 100644 --- a/apps/sim/app/api/files/delete/route.test.ts +++ b/apps/sim/app/api/files/delete/route.test.ts @@ -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') - }) }) diff --git a/apps/sim/app/api/files/delete/route.ts b/apps/sim/app/api/files/delete/route.ts index 8e0bbb7ec5b..4eeeb538747 100644 --- a/apps/sim/app/api/files/delete/route.ts +++ b/apps/sim/app/api/files/delete/route.ts @@ -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() -}) diff --git a/apps/sim/app/api/files/presigned/batch/route.ts b/apps/sim/app/api/files/presigned/batch/route.ts index 014a7d4cfd7..ac5015c9a7d 100644 --- a/apps/sim/app/api/files/presigned/batch/route.ts +++ b/apps/sim/app/api/files/presigned/batch/route.ts @@ -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', - }, - } - ) -}) diff --git a/apps/sim/app/api/files/presigned/route.test.ts b/apps/sim/app/api/files/presigned/route.test.ts index 724aab5d065..9abfa5be2d4 100644 --- a/apps/sim/app/api/files/presigned/route.test.ts +++ b/apps/sim/app/api/files/presigned/route.test.ts @@ -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' - ) - }) - }) }) diff --git a/apps/sim/app/api/files/presigned/route.ts b/apps/sim/app/api/files/presigned/route.ts index 7484712978c..3312434f04d 100644 --- a/apps/sim/app/api/files/presigned/route.ts +++ b/apps/sim/app/api/files/presigned/route.ts @@ -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', - }, - } - ) -}) diff --git a/apps/sim/app/api/files/upload/route.test.ts b/apps/sim/app/api/files/upload/route.test.ts index f0ef4ede98b..cf80cbf9b0d 100644 --- a/apps/sim/app/api/files/upload/route.test.ts +++ b/apps/sim/app/api/files/upload/route.test.ts @@ -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', () => { diff --git a/apps/sim/app/api/files/upload/route.ts b/apps/sim/app/api/files/upload/route.ts index 2bdd3c81d18..e1dc599cad7 100644 --- a/apps/sim/app/api/files/upload/route.ts +++ b/apps/sim/app/api/files/upload/route.ts @@ -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(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() -}) diff --git a/apps/sim/app/api/files/utils.ts b/apps/sim/app/api/files/utils.ts index ac759b45bcc..b6b05f4cbb6 100644 --- a/apps/sim/app/api/files/utils.ts +++ b/apps/sim/app/api/files/utils.ts @@ -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', - }, - }) -} diff --git a/apps/sim/app/api/form/[identifier]/route.ts b/apps/sim/app/api/form/[identifier]/route.ts index d5ed51c4af7..e2f03399104 100644 --- a/apps/sim/app/api/form/[identifier]/route.ts +++ b/apps/sim/app/api/form/[identifier]/route.ts @@ -401,7 +401,3 @@ export const GET = withRouteHandler( } } ) - -export const OPTIONS = withRouteHandler(async (request: NextRequest) => { - return addCorsHeaders(new NextResponse(null, { status: 204 }), request) -}) diff --git a/apps/sim/app/api/mcp/copilot/route.ts b/apps/sim/app/api/mcp/copilot/route.ts index 694009c94c0..9cdb7de7301 100644 --- a/apps/sim/app/api/mcp/copilot/route.ts +++ b/apps/sim/app/api/mcp/copilot/route.ts @@ -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 }) diff --git a/apps/sim/app/api/templates/approved/sanitized/route.ts b/apps/sim/app/api/templates/approved/sanitized/route.ts index a2eba630a50..6fa0e69d7f5 100644 --- a/apps/sim/app/api/templates/approved/sanitized/route.ts +++ b/apps/sim/app/api/templates/approved/sanitized/route.ts @@ -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', - }, - }) -}) diff --git a/apps/sim/app/api/tools/image/route.ts b/apps/sim/app/api/tools/image/route.ts index f6105233285..8ea4af44d70 100644 --- a/apps/sim/app/api/tools/image/route.ts +++ b/apps/sim/app/api/tools/image/route.ts @@ -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', - }, - }) -}) diff --git a/apps/sim/lib/core/security/deployment.ts b/apps/sim/lib/core/security/deployment.ts index c3658b9d39a..58dde19c58d 100644 --- a/apps/sim/lib/core/security/deployment.ts +++ b/apps/sim/lib/core/security/deployment.ts @@ -106,6 +106,7 @@ export function addCorsHeaders(response: NextResponse, request: NextRequest): Ne 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') + response.headers.set('Vary', 'Origin') } return response diff --git a/apps/sim/next.config.ts b/apps/sim/next.config.ts index 558264ea768..cce4e528bcb 100644 --- a/apps/sim/next.config.ts +++ b/apps/sim/next.config.ts @@ -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 diff --git a/apps/sim/proxy.ts b/apps/sim/proxy.ts index ed642956360..37367648c42 100644 --- a/apps/sim/proxy.ts +++ b/apps/sim/proxy.ts @@ -2,12 +2,105 @@ 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') +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_PATH = /^\/api\/workflows\/[^/]+\/execute$/ + +/** + * 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. + */ +function resolveApiCorsPolicy(request: NextRequest): CorsPolicy { + const { pathname } = request.nextUrl + if (pathname.startsWith('/api/auth/oauth2/')) { + return { + origin: '*', + credentials: false, + methods: 'GET, POST, OPTIONS', + headers: 'Content-Type, Authorization, Accept', + } + } + if (pathname === '/api/auth/jwks' || pathname.startsWith('/api/auth/.well-known/')) { + return { + origin: '*', + credentials: false, + methods: 'GET, OPTIONS', + headers: 'Content-Type, Accept', + } + } + if (pathname === '/api/mcp/copilot') { + return { + origin: '*', + credentials: false, + methods: 'GET, POST, OPTIONS, DELETE', + headers: 'Content-Type, Authorization, X-API-Key, X-Requested-With, Accept', + } + } + if (pathname === '/api/form' || pathname.startsWith('/api/form/')) { + // Form embeds run on customer domains; reflect origin to match + // addCorsHeaders in lib/core/security/deployment.ts. + return { + origin: request.headers.get('origin') || '*', + credentials: false, + methods: 'GET, POST, OPTIONS', + headers: 'Content-Type, X-Requested-With', + } + } + if (WORKFLOW_EXECUTE_PATH.test(pathname)) { + return { + origin: '*', + credentials: false, + methods: 'GET,POST,OPTIONS,PUT', + headers: + 'X-CSRF-Token, X-Requested-With, Accept, Accept-Version, Content-Length, Content-MD5, Content-Type, Date, X-Api-Version, X-API-Key', + } + } + 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 +215,19 @@ 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) + } + if (url.pathname === '/api/form' || url.pathname.startsWith('/api/form/')) { + return NextResponse.next() + } + const response = NextResponse.next() + applyCorsHeaders(response, policy) + return response + } + const sessionCookie = getSessionCookie(request) const hasActiveSession = isAuthDisabled || !!sessionCookie @@ -202,6 +308,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).*)', ], diff --git a/docker/app.Dockerfile b/docker/app.Dockerfile index 0c6087e241f..79400246492 100644 --- a/docker/app.Dockerfile +++ b/docker/app.Dockerfile @@ -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 \ From ae031087bc9343832f0cf6e7eaf15a112c85df22 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Tue, 19 May 2026 10:28:51 -0700 Subject: [PATCH 2/6] refactor(cors): consolidate API CORS into proxy as single source of truth MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../api/chat/[identifier]/otp/route.test.ts | 5 - .../app/api/chat/[identifier]/otp/route.ts | 77 +++------ .../app/api/chat/[identifier]/route.test.ts | 14 +- apps/sim/app/api/chat/[identifier]/route.ts | 81 +++------- .../app/api/chat/[identifier]/sso/route.ts | 13 +- apps/sim/app/api/chat/utils.test.ts | 3 - .../api/form/[identifier]/otp/route.test.ts | 4 - .../app/api/form/[identifier]/otp/route.ts | 80 +++------- apps/sim/app/api/form/[identifier]/route.ts | 150 +++++++----------- apps/sim/app/api/form/utils.test.ts | 19 +-- apps/sim/lib/core/security/deployment.ts | 24 +-- apps/sim/proxy.ts | 13 +- 12 files changed, 150 insertions(+), 333 deletions(-) diff --git a/apps/sim/app/api/chat/[identifier]/otp/route.test.ts b/apps/sim/app/api/chat/[identifier]/otp/route.test.ts index 547a164b069..a860df8eb28 100644 --- a/apps/sim/app/api/chat/[identifier]/otp/route.test.ts +++ b/apps/sim/app/api/chat/[identifier]/otp/route.test.ts @@ -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('OTP Email') - mockAddCorsHeaders.mockImplementation((response: unknown) => response) mockCreateSuccessResponse.mockImplementation((data: unknown) => ({ json: () => Promise.resolve(data), status: 200, diff --git a/apps/sim/app/api/chat/[identifier]/otp/route.ts b/apps/sim/app/api/chat/[identifier]/otp/route.ts index fcccc003e86..aeb1b69f450 100644 --- a/apps/sim/app/api/chat/[identifier]/otp/route.ts +++ b/apps/sim/app/api/chat/[identifier]/otp/route.ts @@ -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) } } ) diff --git a/apps/sim/app/api/chat/[identifier]/route.test.ts b/apps/sim/app/api/chat/[identifier]/route.test.ts index 426545aece0..b1b36d60b6d 100644 --- a/apps/sim/app/api/chat/[identifier]/route.test.ts +++ b/apps/sim/app/api/chat/[identifier]/route.test.ts @@ -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) => { diff --git a/apps/sim/app/api/chat/[identifier]/route.ts b/apps/sim/app/api/chat/[identifier]/route.ts index f35d950a21c..7a4a12d5754 100644 --- a/apps/sim/app/api/chat/[identifier]/route.ts +++ b/apps/sim/app/api/chat/[identifier]/route.ts @@ -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) } } ) diff --git a/apps/sim/app/api/chat/[identifier]/sso/route.ts b/apps/sim/app/api/chat/[identifier]/sso/route.ts index c6878876a90..c6ab98cfe94 100644 --- a/apps/sim/app/api/chat/[identifier]/sso/route.ts +++ b/apps/sim/app/api/chat/[identifier]/sso/route.ts @@ -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 }) } ) diff --git a/apps/sim/app/api/chat/utils.test.ts b/apps/sim/app/api/chat/utils.test.ts index 60395c0bbd1..86e46340a92 100644 --- a/apps/sim/app/api/chat/utils.test.ts +++ b/apps/sim/app/api/chat/utils.test.ts @@ -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, })) diff --git a/apps/sim/app/api/form/[identifier]/otp/route.test.ts b/apps/sim/app/api/form/[identifier]/otp/route.test.ts index 4b3b13441d0..5a0a9eb1033 100644 --- a/apps/sim/app/api/form/[identifier]/otp/route.test.ts +++ b/apps/sim/app/api/form/[identifier]/otp/route.test.ts @@ -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('OTP Email') - mockAddCorsHeaders.mockImplementation((response: unknown) => response) mockCreateSuccessResponse.mockImplementation((data: unknown) => ({ json: () => Promise.resolve(data), status: 200, diff --git a/apps/sim/app/api/form/[identifier]/otp/route.ts b/apps/sim/app/api/form/[identifier]/otp/route.ts index 0d9804efa55..55f3f493ca0 100644 --- a/apps/sim/app/api/form/[identifier]/otp/route.ts +++ b/apps/sim/app/api/form/[identifier]/otp/route.ts @@ -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) } } ) diff --git a/apps/sim/app/api/form/[identifier]/route.ts b/apps/sim/app/api/form/[identifier]/route.ts index e2f03399104..46b0f1e068f 100644 --- a/apps/sim/app/api/form/[identifier]/route.ts +++ b/apps/sim/app/api/form/[identifier]/route.ts @@ -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 | 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,42 +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) } } ) diff --git a/apps/sim/app/api/form/utils.test.ts b/apps/sim/app/api/form/utils.test.ts index 1826d9386c1..d6b51c2d778 100644 --- a/apps/sim/app/api/form/utils.test.ts +++ b/apps/sim/app/api/form/utils.test.ts @@ -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, })) diff --git a/apps/sim/lib/core/security/deployment.ts b/apps/sim/lib/core/security/deployment.ts index 58dde19c58d..818ff588edb 100644 --- a/apps/sim/lib/core/security/deployment.ts +++ b/apps/sim/lib/core/security/deployment.ts @@ -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,25 +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') - response.headers.set('Vary', 'Origin') - } - - return response -} - /** * Checks if an email matches the allowed emails list (exact match or domain match) */ diff --git a/apps/sim/proxy.ts b/apps/sim/proxy.ts index 37367648c42..35d74ccad08 100644 --- a/apps/sim/proxy.ts +++ b/apps/sim/proxy.ts @@ -51,9 +51,13 @@ function resolveApiCorsPolicy(request: NextRequest): CorsPolicy { headers: 'Content-Type, Authorization, X-API-Key, X-Requested-With, Accept', } } - if (pathname === '/api/form' || pathname.startsWith('/api/form/')) { - // Form embeds run on customer domains; reflect origin to match - // addCorsHeaders in lib/core/security/deployment.ts. + if ( + pathname === '/api/form' || + pathname.startsWith('/api/form/') || + pathname.startsWith('/api/chat/') + ) { + // Chat and form embeds run on customer domains; reflect the request + // origin and omit credentials (auth uses signed tokens, not cookies). return { origin: request.headers.get('origin') || '*', credentials: false, @@ -220,9 +224,6 @@ export async function proxy(request: NextRequest) { if (request.method === 'OPTIONS') { return buildPreflightResponse(policy) } - if (url.pathname === '/api/form' || url.pathname.startsWith('/api/form/')) { - return NextResponse.next() - } const response = NextResponse.next() applyCorsHeaders(response, policy) return response From b2956f1fae5b13f8646940c15c5420826887af5e Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Tue, 19 May 2026 11:16:17 -0700 Subject: [PATCH 3/6] 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 --- apps/sim/proxy.test.ts | 107 +++++++++++++++++++++++++++++++++++++++++ apps/sim/proxy.ts | 78 +++++++++++++++++------------- 2 files changed, 152 insertions(+), 33 deletions(-) create mode 100644 apps/sim/proxy.test.ts diff --git a/apps/sim/proxy.test.ts b/apps/sim/proxy.test.ts new file mode 100644 index 00000000000..d0843132f56 --- /dev/null +++ b/apps/sim/proxy.test.ts @@ -0,0 +1,107 @@ +/** + * @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', () => { + for (const path of ['/api/chat/abc', '/api/form', '/api/form/xyz']) { + const policy = resolveApiCorsPolicy(makeRequest(path, 'https://customer.example')) + expect(policy).toEqual({ + origin: 'https://customer.example', + credentials: false, + methods: 'GET, POST, OPTIONS', + headers: 'Content-Type, X-Requested-With', + }) + } + }) + + it('falls back to wildcard for chat/form when no origin header is present', () => { + expect(resolveApiCorsPolicy(makeRequest('/api/chat/abc')).origin).toBe('*') + }) + + 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) + } + } + }) +}) diff --git a/apps/sim/proxy.ts b/apps/sim/proxy.ts index 35d74ccad08..d43e179da2f 100644 --- a/apps/sim/proxy.ts +++ b/apps/sim/proxy.ts @@ -9,7 +9,7 @@ import { getClientIp } from './lib/core/utils/request' const logger = createLogger('Proxy') -interface CorsPolicy { +export interface CorsPolicy { origin: string credentials: boolean methods: string @@ -19,60 +19,72 @@ interface CorsPolicy { 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_PATH = /^\/api\/workflows\/[^/]+\/execute$/ +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' -/** - * 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. - */ -function resolveApiCorsPolicy(request: NextRequest): CorsPolicy { - const { pathname } = request.nextUrl - if (pathname.startsWith('/api/auth/oauth2/')) { - return { +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', - } - } - if (pathname === '/api/auth/jwks' || pathname.startsWith('/api/auth/.well-known/')) { - return { + }), + }, + { + match: (p) => p === '/api/auth/jwks' || p.startsWith('/api/auth/.well-known/'), + policy: () => ({ origin: '*', credentials: false, methods: 'GET, OPTIONS', headers: 'Content-Type, Accept', - } - } - if (pathname === '/api/mcp/copilot') { - return { + }), + }, + { + 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', - } - } - if ( - pathname === '/api/form' || - pathname.startsWith('/api/form/') || - pathname.startsWith('/api/chat/') - ) { + }), + }, + { // Chat and form embeds run on customer domains; reflect the request // origin and omit credentials (auth uses signed tokens, not cookies). - return { + match: (p) => p === '/api/form' || p.startsWith('/api/form/') || p.startsWith('/api/chat/'), + policy: (request) => ({ origin: request.headers.get('origin') || '*', credentials: false, methods: 'GET, POST, OPTIONS', headers: 'Content-Type, X-Requested-With', - } - } - if (WORKFLOW_EXECUTE_PATH.test(pathname)) { - return { + }), + }, + { + match: (p) => /^\/api\/workflows\/[^/]+\/execute$/.test(p), + policy: () => ({ origin: '*', credentials: false, methods: 'GET,POST,OPTIONS,PUT', - headers: - 'X-CSRF-Token, X-Requested-With, Accept, Accept-Version, Content-Length, Content-MD5, Content-Type, Date, X-Api-Version, X-API-Key', - } + 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', From 99bb0f9ae4ba4a019b18ec1b880987db4793cb71 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Tue, 19 May 2026 11:26:15 -0700 Subject: [PATCH 4/6] fix(cors): scope embed CORS rule to /api/{chat,form}/[identifier] only MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- apps/sim/proxy.test.ts | 27 +++++++++++++++++++++++++-- apps/sim/proxy.ts | 18 +++++++++++++++--- 2 files changed, 40 insertions(+), 5 deletions(-) diff --git a/apps/sim/proxy.test.ts b/apps/sim/proxy.test.ts index d0843132f56..76a6ee90238 100644 --- a/apps/sim/proxy.test.ts +++ b/apps/sim/proxy.test.ts @@ -45,7 +45,14 @@ describe('resolveApiCorsPolicy', () => { }) it('reflects origin for chat and form embeds, never sets credentials', () => { - for (const path of ['/api/chat/abc', '/api/form', '/api/form/xyz']) { + 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', @@ -56,10 +63,26 @@ describe('resolveApiCorsPolicy', () => { } }) - it('falls back to wildcard for chat/form when no origin header is present', () => { + it('falls back to wildcard for chat/form embeds when no origin header is present', () => { expect(resolveApiCorsPolicy(makeRequest('/api/chat/abc')).origin).toBe('*') }) + 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') diff --git a/apps/sim/proxy.ts b/apps/sim/proxy.ts index d43e179da2f..44c186396f4 100644 --- a/apps/sim/proxy.ts +++ b/apps/sim/proxy.ts @@ -22,6 +22,14 @@ const DEFAULT_API_ALLOWED_HEADERS = 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' +/** + * Matches embed endpoints: /api/{chat,form}/{identifier} and subroutes + * (/otp, /sso). The identifier segment explicitly excludes the + * workspace-internal subpaths `manage` and `validate` so those continue + * to use the default credentialed policy. + */ +const EMBED_PATH = /^\/api\/(chat|form)\/(?!manage(\/|$)|validate(\/|$))[^/]+(\/(otp|sso))?$/ + interface CorsRule { match: (pathname: string) => boolean policy: (request: NextRequest) => CorsPolicy @@ -56,9 +64,13 @@ const CORS_RULES: readonly CorsRule[] = [ }), }, { - // Chat and form embeds run on customer domains; reflect the request - // origin and omit credentials (auth uses signed tokens, not cookies). - match: (p) => p === '/api/form' || p.startsWith('/api/form/') || p.startsWith('/api/chat/'), + // 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) => EMBED_PATH.test(p), policy: (request) => ({ origin: request.headers.get('origin') || '*', credentials: false, From 80aa1026a731f2aaf4cfae02f9e4e8c0c268d1d5 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Tue, 19 May 2026 11:28:57 -0700 Subject: [PATCH 5/6] 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 --- apps/sim/proxy.test.ts | 8 ++++++++ apps/sim/proxy.ts | 27 +++++++++++++++++++++------ 2 files changed, 29 insertions(+), 6 deletions(-) diff --git a/apps/sim/proxy.test.ts b/apps/sim/proxy.test.ts index 76a6ee90238..6886ab06d6b 100644 --- a/apps/sim/proxy.test.ts +++ b/apps/sim/proxy.test.ts @@ -67,6 +67,14 @@ describe('resolveApiCorsPolicy', () => { 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', diff --git a/apps/sim/proxy.ts b/apps/sim/proxy.ts index 44c186396f4..17599ac4226 100644 --- a/apps/sim/proxy.ts +++ b/apps/sim/proxy.ts @@ -23,12 +23,27 @@ 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' /** - * Matches embed endpoints: /api/{chat,form}/{identifier} and subroutes - * (/otp, /sso). The identifier segment explicitly excludes the - * workspace-internal subpaths `manage` and `validate` so those continue - * to use the default credentialed policy. + * 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_PATH = /^\/api\/(chat|form)\/(?!manage(\/|$)|validate(\/|$))[^/]+(\/(otp|sso))?$/ +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 @@ -70,7 +85,7 @@ const CORS_RULES: readonly CorsRule[] = [ // 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) => EMBED_PATH.test(p), + match: (p) => isEmbedPath(p), policy: (request) => ({ origin: request.headers.get('origin') || '*', credentials: false, From 5968f3b45e9c992a7dc4f40318ea4c3832a2c1f0 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Tue, 19 May 2026 11:42:32 -0700 Subject: [PATCH 6/6] 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 --- apps/sim/proxy.test.ts | 9 ++++++++- apps/sim/proxy.ts | 3 ++- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/apps/sim/proxy.test.ts b/apps/sim/proxy.test.ts index 6886ab06d6b..0282d670cd3 100644 --- a/apps/sim/proxy.test.ts +++ b/apps/sim/proxy.test.ts @@ -57,12 +57,19 @@ describe('resolveApiCorsPolicy', () => { expect(policy).toEqual({ origin: 'https://customer.example', credentials: false, - methods: 'GET, POST, OPTIONS', + 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('*') }) diff --git a/apps/sim/proxy.ts b/apps/sim/proxy.ts index 17599ac4226..a5b8ea6df90 100644 --- a/apps/sim/proxy.ts +++ b/apps/sim/proxy.ts @@ -89,7 +89,8 @@ const CORS_RULES: readonly CorsRule[] = [ policy: (request) => ({ origin: request.headers.get('origin') || '*', credentials: false, - methods: 'GET, POST, OPTIONS', + // PUT is required for OTP verification on /[identifier]/otp. + methods: 'GET, POST, PUT, OPTIONS', headers: 'Content-Type, X-Requested-With', }), },