Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 9 additions & 7 deletions apps/sim/proxy.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ describe('resolveApiCorsPolicy', () => {
expect(policy.headers).toContain('X-API-Key')
})

it('reflects origin for chat and form embeds, never sets credentials', () => {
it('reflects origin for chat and form embeds with credentials enabled', () => {
const paths = [
'/api/chat/abc',
'/api/chat/abc/otp',
Expand All @@ -56,30 +56,32 @@ describe('resolveApiCorsPolicy', () => {
const policy = resolveApiCorsPolicy(makeRequest(path, 'https://customer.example'))
expect(policy).toEqual({
origin: 'https://customer.example',
credentials: false,
credentials: true,
methods: 'GET, POST, PUT, OPTIONS',
headers: 'Content-Type, X-Requested-With',
})
}
})

it('drops credentials on embed policy when Origin header is absent (CORS spec invariant)', () => {
const policy = resolveApiCorsPolicy(makeRequest('/api/chat/abc'))
expect(policy.origin).toBe('*')
expect(policy.credentials).toBe(false)
})

it('allows PUT on the embed policy (used by OTP verification on /[identifier]/otp)', () => {
const policy = resolveApiCorsPolicy(
makeRequest('/api/chat/abc/otp', 'https://customer.example')
)
expect(policy.methods).toContain('PUT')
})

it('falls back to wildcard for chat/form embeds when no origin header is present', () => {
expect(resolveApiCorsPolicy(makeRequest('/api/chat/abc')).origin).toBe('*')
})

it('applies the embed policy to future identifier subroutes (not just /otp, /sso)', () => {
const policy = resolveApiCorsPolicy(
makeRequest('/api/chat/abc/transcript', 'https://customer.example')
)
expect(policy.origin).toBe('https://customer.example')
expect(policy.credentials).toBe(false)
expect(policy.credentials).toBe(true)
})

it('uses the default credentialed policy for workspace-internal chat/form routes', () => {
Expand Down
45 changes: 13 additions & 32 deletions apps/sim/proxy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,19 +22,10 @@ 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'

/**
* 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.
*/
/** Subpaths under /api/{chat,form}/* that serve the workspace UI, not embeds. */
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.
*/
/** True for /api/{chat,form}/[identifier] and any deeper subroute. */
function isEmbedPath(pathname: string): boolean {
const segments = pathname.split('/')
if (segments.length < 4) return false
Expand Down Expand Up @@ -79,20 +70,16 @@ const CORS_RULES: readonly CorsRule[] = [
}),
},
{
// Embed endpoints: /api/chat/[identifier] and /api/form/[identifier]
// (plus their /otp and /sso subroutes). These run on customer domains —
// reflect the request origin and omit credentials (auth uses signed
// tokens, not cookies). Workspace-internal subpaths (`manage`, `validate`,
// and the bare collection routes) are deliberately excluded so they
// continue to receive the default credentialed policy.
match: (p) => isEmbedPath(p),
policy: (request) => ({
origin: request.headers.get('origin') || '*',
credentials: false,
// PUT is required for OTP verification on /[identifier]/otp.
methods: 'GET, POST, PUT, OPTIONS',
headers: 'Content-Type, X-Requested-With',
}),
policy: (request) => {
const requestOrigin = request.headers.get('origin')
return {
origin: requestOrigin || '*',
credentials: !!requestOrigin,
methods: 'GET, POST, PUT, OPTIONS',
headers: 'Content-Type, X-Requested-With',
}
},
},
{
match: (p) => /^\/api\/workflows\/[^/]+\/execute$/.test(p),
Expand All @@ -105,10 +92,7 @@ const CORS_RULES: readonly CorsRule[] = [
},
]

/**
* 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.
*/
/** Single source of truth for /api/* CORS — resolved at request time, not baked at build. */
export function resolveApiCorsPolicy(request: NextRequest): CorsPolicy {
const { pathname } = request.nextUrl
for (const rule of CORS_RULES) {
Expand All @@ -134,10 +118,7 @@ function applyCorsHeaders(response: NextResponse, policy: CorsPolicy): void {
}
}

/**
* Short-circuit preflight: Next's auto-OPTIONS for route handlers without
* an explicit OPTIONS export does not carry middleware headers.
*/
/** Next's auto-OPTIONS doesn't carry middleware headers, so we answer preflight here. */
function buildPreflightResponse(policy: CorsPolicy): NextResponse {
const response = new NextResponse(null, { status: 204 })
applyCorsHeaders(response, policy)
Expand Down
Loading