Skip to content
2 changes: 2 additions & 0 deletions packages/server/src/services/chatflows/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -374,6 +374,8 @@ const getSinglePublicChatbotConfig = async (chatflowId: string): Promise<any> =>
}
})
}
delete parsedConfig.allowedOrigins
delete parsedConfig.allowedOriginsError
return { ...parsedConfig, uploads: uploadsConfig, flowData: dbResponse.flowData, isTTSEnabled }
} catch (e) {
throw new InternalFlowiseError(StatusCodes.INTERNAL_SERVER_ERROR, `Error parsing Chatbot Config for Chatflow ${chatflowId}`)
Expand Down
62 changes: 53 additions & 9 deletions packages/server/src/utils/XSS.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { Request, Response, NextFunction } from 'express'
import sanitizeHtml from 'sanitize-html'
import { isPredictionRequest, extractChatflowId, validateChatflowDomain } from './domainValidation'

export function sanitizeMiddleware(req: Request, res: Response, next: NextFunction): void {
// decoding is necessary as the url is encoded by the browser
Expand All @@ -21,21 +22,64 @@ export function sanitizeMiddleware(req: Request, res: Response, next: NextFuncti

export function getAllowedCorsOrigins(): string {
// Expects FQDN separated by commas, otherwise nothing or * for all.
return process.env.CORS_ORIGINS ?? '*'
// return process.env.CORS_ORIGINS ?? '*'
return process.env.CORS_ORIGINS as string
}

function parseAllowedOrigins(allowedOrigins: string): string[] {
if (!allowedOrigins) {
return []
}
if (allowedOrigins === '*') {
return ['*']
}
return allowedOrigins
.split(',')
.map((origin) => origin.trim().toLowerCase())
.filter((origin) => origin.length > 0)
}

export function getCorsOptions(): any {
const corsOptions = {
origin: function (origin: string | undefined, callback: (err: Error | null, allow?: boolean) => void) {
const allowedOrigins = getAllowedCorsOrigins()
if (!origin || allowedOrigins == '*' || allowedOrigins.indexOf(origin) !== -1) {
callback(null, true)
} else {
callback(null, false)
return (req: any, callback: (err: Error | null, options?: any) => void) => {
const corsOptions = {
origin: async (origin: string | undefined, originCallback: (err: Error | null, allow?: boolean) => void) => {
const allowedOrigins = getAllowedCorsOrigins()
const isPredictionReq = isPredictionRequest(req.url)

if (!origin || allowedOrigins === '*') {
await checkRequestType(isPredictionReq, req, origin, originCallback)
} else {
const allowedOriginsList = parseAllowedOrigins(allowedOrigins)
if (origin && allowedOriginsList.includes(origin)) {
await checkRequestType(isPredictionReq, req, origin, originCallback)
} else {
originCallback(null, false)
}
}
}
}
callback(null, corsOptions)
}
}
Comment on lines 27 to +62
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Critical: global CORS gate blocks chatflow-level validation for predictions

With allowedOrigins unset (''), any request with an Origin header is denied before checkRequestType runs. This breaks prediction requests that rely on per-chatflow allowedOrigins.

Apply this diff to evaluate chatflow rules first for prediction requests, and use OR semantics with the global allowlist:

 export function getCorsOptions(): any {
-    return (req: any, callback: (err: Error | null, options?: any) => void) => {
+    return (req: any, callback: (err: Error | null, options?: any) => void) => {
         const corsOptions = {
-            origin: async (origin: string | undefined, originCallback: (err: Error | null, allow?: boolean) => void) => {
-                const allowedOrigins = getAllowedCorsOrigins()
-                const isPredictionReq = isPredictionRequest(req.url)
-
-                if (!origin || allowedOrigins === '*') {
-                    await checkRequestType(isPredictionReq, req, origin, originCallback)
-                } else {
-                    const allowedOriginsList = parseAllowedOrigins(allowedOrigins)
-                    if (origin && allowedOriginsList.includes(origin)) {
-                        await checkRequestType(isPredictionReq, req, origin, originCallback)
-                    } else {
-                        originCallback(null, false)
-                    }
-                }
-            }
+            origin: async (origin: string | undefined, originCallback: (err: Error | null, allow?: boolean) => void) => {
+                const allowedOrigins = getAllowedCorsOrigins()
+                const isPredictionReq = isPredictionRequest(req.url)
+                const allowedList = parseAllowedOrigins(allowedOrigins)
+                const originLc = origin?.toLowerCase()
+
+                // Always allow no-Origin requests (same-origin, server-to-server)
+                if (!originLc) return originCallback(null, true)
+
+                // Global allow: '*' or exact match
+                const globallyAllowed = allowedOrigins === '*' || allowedList.includes(originLc)
+
+                if (isPredictionReq) {
+                    // Per-chatflow allowlist OR globally allowed
+                    const chatflowAllowed = await (async () => {
+                        const chatflowId = extractChatflowId(req.url)
+                        return chatflowId ? await validateChatflowDomain(chatflowId, originLc, req.user?.activeWorkspaceId) : true
+                    })()
+                    return originCallback(null, globallyAllowed || chatflowAllowed)
+                }
+
+                // Non-prediction: rely on global policy only
+                return originCallback(null, globallyAllowed)
+            }
         }
         callback(null, corsOptions)
     }
 }

Also normalize the comparison by lowercasing origin before includes().

Committable suggestion skipped: line range outside the PR's diff.

🤖 Prompt for AI Agents
In packages/server/src/utils/XSS.ts around lines 41-62, the origin handler
currently blocks prediction requests when the global allowedOrigins is unset and
runs the global check before the chatflow-level check; change the logic so that
for prediction requests you evaluate the chatflow rule (checkRequestType) first
and accept if it allows the request, otherwise fall back to the global
allowlist; when validating against lists normalize origin to lowercase and
compare against a lowercased allowedOrigins list, and treat the global allowlist
OR the chatflow allowlist as permissive (i.e., allow if either permits) so
prediction requests aren’t rejected prematurely.


async function checkRequestType(
isPredictionReq: boolean,
req: any,
origin: string | undefined,
originCallback: (err: Error | null, allow?: boolean) => void
) {
if (isPredictionReq) {
const chatflowId = extractChatflowId(req.url)
if (chatflowId && origin) {
const isAllowed = await validateChatflowDomain(chatflowId, origin, req.user?.activeWorkspaceId)

originCallback(null, isAllowed)
} else {
originCallback(null, true)
}
} else {
originCallback(null, true)
}
return corsOptions
}

export function getAllowedIframeOrigins(): string {
Expand Down
103 changes: 103 additions & 0 deletions packages/server/src/utils/domainValidation.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
import chatflowsService from '../services/chatflows'
import logger from './logger'

/**
* Validates if the origin is allowed for a specific chatflow
* @param chatflowId - The chatflow ID to validate against
* @param origin - The origin URL to validate
* @param workspaceId - Optional workspace ID for enterprise features
* @returns Promise<boolean> - True if domain is allowed, false otherwise
*/
async function validateChatflowDomain(chatflowId: string, origin: string, workspaceId?: string): Promise<boolean> {

Check warning on line 11 in packages/server/src/utils/domainValidation.ts

View workflow job for this annotation

GitHub Actions / build (ubuntu-latest, 18.15.0)

'workspaceId' is defined but never used. Allowed unused args must match /^_/u
try {
// TODO: Add workspaceId from here
const chatflow = await chatflowsService.getChatflowById(chatflowId)

if (!chatflow?.chatbotConfig) {
logger.info(`No chatbotConfig found for chatflow ${chatflowId}, allowing domain`)
return true
}

const config = JSON.parse(chatflow.chatbotConfig)

// If no allowed origins configured or first entry is empty, allow all
if (!config.allowedOrigins?.length || config.allowedOrigins[0] === '') {
logger.info(`No domain restrictions configured for chatflow ${chatflowId}`)
return true
}

const originHost = new URL(origin).host
const isAllowed = config.allowedOrigins.some((domain: string) => {
try {
const allowedOrigin = new URL(domain).host
return originHost === allowedOrigin
} catch (error) {
logger.warn(`Invalid domain format in allowedOrigins: ${domain}`)
return false
}
})

logger.info(`Domain validation for ${origin} against chatflow ${chatflowId}: ${isAllowed}`)
return isAllowed
} catch (error) {
logger.error(`Error validating domain for chatflow ${chatflowId}:`, error)
return false
}
}

/**
* Extracts chatflow ID from prediction URL
* @param url - The request URL
* @returns string | null - The chatflow ID or null if not found
*/
function extractChatflowId(url: string): string | null {
try {
const urlParts = url.split('/')
const predictionIndex = urlParts.indexOf('prediction')

if (predictionIndex !== -1 && urlParts.length > predictionIndex + 1) {
const chatflowId = urlParts[predictionIndex + 1]
// Remove query parameters if present
return chatflowId.split('?')[0]
}

return null
} catch (error) {
logger.error('Error extracting chatflow ID from URL:', error)
return null
}
}

/**
* Validates if a request is a prediction request
* @param url - The request URL
* @returns boolean - True if it's a prediction request
*/
function isPredictionRequest(url: string): boolean {
return url.includes('/prediction/')
}

/**
* Get the custom error message for unauthorized origin
* @param chatflowId - The chatflow ID
* @param workspaceId - Optional workspace ID
* @returns Promise<string> - Custom error message or default
*/
async function getUnauthorizedOriginError(chatflowId: string, workspaceId?: string): Promise<string> {

Check warning on line 86 in packages/server/src/utils/domainValidation.ts

View workflow job for this annotation

GitHub Actions / build (ubuntu-latest, 18.15.0)

'workspaceId' is defined but never used. Allowed unused args must match /^_/u
try {
// TODO: Add workspaceId from here
const chatflow = await chatflowsService.getChatflowById(chatflowId)

if (chatflow?.chatbotConfig) {
const config = JSON.parse(chatflow.chatbotConfig)
return config.allowedOriginsError || 'This site is not allowed to access this chatbot'
}

return 'This site is not allowed to access this chatbot'
} catch (error) {
logger.error(`Error getting unauthorized origin error for chatflow ${chatflowId}:`, error)
return 'This site is not allowed to access this chatbot'
}
}

export { isPredictionRequest, extractChatflowId, validateChatflowDomain, getUnauthorizedOriginError }
Loading