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 \