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
10 changes: 8 additions & 2 deletions packages/server/src/services/chatflows/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import documentStoreService from '../../services/documentstore'
import { constructGraphs, getAppVersion, getEndingNodes, getTelemetryFlowObj, isFlowValidForStream } from '../../utils'
import { sanitizeAllowedUploadMimeTypesFromConfig } from '../../utils/fileValidation'
import { containsBase64File, updateFlowDataWithFilePaths } from '../../utils/fileRepository'
import { sanitizeFlowDataForPublicEndpoint } from '../../utils/sanitizeFlowData'
import { getRunningExpressApp } from '../../utils/getRunningExpressApp'
import { utilGetUploadsConfig } from '../../utils/getUploadsConfig'
import logger from '../../utils/logger'
Expand Down Expand Up @@ -375,7 +376,7 @@ const updateChatflow = async (
}

// Get specific chatflow chatbotConfig via id (PUBLIC endpoint, used to retrieve config for embedded chat)
// Safe as public endpoint as chatbotConfig doesn't contain sensitive credential
// flowData is sanitized before returning — password, file, folder inputs and credential references are stripped
const getSinglePublicChatbotConfig = async (chatflowId: string): Promise<any> => {
try {
const appServer = getRunningExpressApp()
Expand Down Expand Up @@ -404,7 +405,12 @@ const getSinglePublicChatbotConfig = async (chatflowId: string): Promise<any> =>
}
delete parsedConfig.allowedOrigins
delete parsedConfig.allowedOriginsError
return { ...parsedConfig, uploads: uploadsConfig, flowData: dbResponse.flowData, isTTSEnabled }
return {
...parsedConfig,
uploads: uploadsConfig,
flowData: sanitizeFlowDataForPublicEndpoint(dbResponse.flowData),
isTTSEnabled
}
} catch (e) {
throw new InternalFlowiseError(StatusCodes.INTERNAL_SERVER_ERROR, `Error parsing Chatbot Config for Chatflow ${chatflowId}`)
}
Expand Down
69 changes: 69 additions & 0 deletions packages/server/src/utils/sanitizeFlowData.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import { INodeParams } from 'flowise-components'
import { IReactFlowObject } from '../Interface'

const SENSITIVE_HEADER_KEYS = new Set(['authorization', 'x-api-key', 'x-auth-token', 'cookie'])

/**
* Sanitizes flowData before returning it from a public endpoint.
* Strips password/file/folder inputs, credential ID references, and
* auth-related HTTP headers so sensitive credentials are never exposed.
*/
export const sanitizeFlowDataForPublicEndpoint = (flowDataString: string): string => {
if (!flowDataString) return flowDataString
try {
const flowData: IReactFlowObject = JSON.parse(flowDataString)
if (!Array.isArray(flowData.nodes)) return flowDataString

for (const node of flowData.nodes) {
if (!node.data) continue

// Remove credential ID reference
delete node.data.credential

const inputs = node.data.inputs
const inputParams: INodeParams[] = node.data.inputParams

if (!inputs || !inputParams) continue

const sanitizedInputs: Record<string, unknown> = {}
for (const key of Object.keys(inputs)) {
const param = inputParams.find((p) => p.name === key)

if (param && (param.type === 'password' || param.type === 'file' || param.type === 'folder')) {
continue
}

if (key === 'headers' && inputs[key]) {
try {
const rawHeaders = inputs[key]
// Array format: [{ key: string, value: string }, ...] (e.g. HTTP agentflow node)
if (Array.isArray(rawHeaders)) {
sanitizedInputs[key] = rawHeaders.filter(
(h: { key?: string; value?: string }) => !h.key || !SENSITIVE_HEADER_KEYS.has(h.key.toLowerCase())
)
continue
}
// Object/string format: Record<string, string> or JSON string thereof
const headers: Record<string, string> =
typeof rawHeaders === 'string' ? JSON.parse(rawHeaders) : { ...(rawHeaders as object) }
for (const h of Object.keys(headers)) {
if (SENSITIVE_HEADER_KEYS.has(h.toLowerCase())) delete headers[h]
}
sanitizedInputs[key] = typeof rawHeaders === 'string' ? JSON.stringify(headers) : headers
continue
} catch {
// Drop headers that cannot be parsed
continue
}
}

sanitizedInputs[key] = inputs[key]
}
node.data.inputs = sanitizedInputs
}

return JSON.stringify(flowData)
} catch {
return JSON.stringify({ nodes: [], edges: [] })
}
}
2 changes: 2 additions & 0 deletions packages/server/test/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { getRunningExpressApp } from '../src/utils/getRunningExpressApp'
import { organizationUserRouteTest } from './routes/v1/organization-user.route.test'
import { userRouteTest } from './routes/v1/user.route.test'
import { apiKeyTest } from './utils/api-key.util.test'
import { sanitizeFlowDataTest } from './utils/sanitizeFlowData.test'

// ⏱️ Extend test timeout to 6 minutes for long setups (increase as tests grow)
jest.setTimeout(360000)
Expand All @@ -25,4 +26,5 @@ describe('Routes Test', () => {

describe('Utils Test', () => {
apiKeyTest()
sanitizeFlowDataTest()
})
160 changes: 160 additions & 0 deletions packages/server/test/utils/sanitizeFlowData.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
import { sanitizeFlowDataForPublicEndpoint } from '../../src/utils/sanitizeFlowData'

const makeFlowData = (nodes: object[], edges: object[] = []) => JSON.stringify({ nodes, edges, viewport: { x: 0, y: 0, zoom: 1 } })

const makeNode = (inputs: Record<string, unknown>, inputParams: object[], extra: object = {}) => ({
id: 'node_0',
position: { x: 0, y: 0 },
type: 'customNode',
data: {
id: 'node_0',
name: 'testNode',
label: 'Test Node',
inputs,
inputParams,
...extra
}
})

export function sanitizeFlowDataTest() {
describe('sanitizeFlowDataForPublicEndpoint', () => {
it('strips password-type inputs', () => {
const flowData = makeFlowData([
makeNode({ apiKey: 'sk-secret', model: 'gpt-4' }, [
{ name: 'apiKey', type: 'password' },
{ name: 'model', type: 'string' }
])
])
const result = JSON.parse(sanitizeFlowDataForPublicEndpoint(flowData))
expect(result.nodes[0].data.inputs).not.toHaveProperty('apiKey')
expect(result.nodes[0].data.inputs.model).toBe('gpt-4')
})

it('strips file-type inputs', () => {
const flowData = makeFlowData([
makeNode({ filePath: '/data/secret.pdf', label: 'loader' }, [
{ name: 'filePath', type: 'file' },
{ name: 'label', type: 'string' }
])
])
const result = JSON.parse(sanitizeFlowDataForPublicEndpoint(flowData))
expect(result.nodes[0].data.inputs).not.toHaveProperty('filePath')
expect(result.nodes[0].data.inputs.label).toBe('loader')
})

it('strips folder-type inputs', () => {
const flowData = makeFlowData([
makeNode({ folderPath: '/home/user/docs', name: 'ingest' }, [
{ name: 'folderPath', type: 'folder' },
{ name: 'name', type: 'string' }
])
])
const result = JSON.parse(sanitizeFlowDataForPublicEndpoint(flowData))
expect(result.nodes[0].data.inputs).not.toHaveProperty('folderPath')
expect(result.nodes[0].data.inputs.name).toBe('ingest')
})

it('removes credential field from node data', () => {
const flowData = makeFlowData([
makeNode({ model: 'gpt-4' }, [{ name: 'model', type: 'string' }], { credential: 'cred-uuid-123' })
])
const result = JSON.parse(sanitizeFlowDataForPublicEndpoint(flowData))
expect(result.nodes[0].data).not.toHaveProperty('credential')
})

it('removes Authorization header from headers input, preserves other headers', () => {
const headers = JSON.stringify({ Authorization: 'Bearer secret-token', 'Content-Type': 'application/json' })
const flowData = makeFlowData([
makeNode({ headers, url: 'https://example.com' }, [
{ name: 'headers', type: 'json' },
{ name: 'url', type: 'string' }
])
])
const result = JSON.parse(sanitizeFlowDataForPublicEndpoint(flowData))
const sanitizedHeaders = JSON.parse(result.nodes[0].data.inputs.headers)
expect(sanitizedHeaders).not.toHaveProperty('Authorization')
expect(sanitizedHeaders['Content-Type']).toBe('application/json')
})

it('removes x-api-key header case-insensitively', () => {
const headers = JSON.stringify({ 'X-API-Key': 'my-key', Accept: 'application/json' })
const flowData = makeFlowData([makeNode({ headers }, [{ name: 'headers', type: 'json' }])])
const result = JSON.parse(sanitizeFlowDataForPublicEndpoint(flowData))
const sanitizedHeaders = JSON.parse(result.nodes[0].data.inputs.headers)
expect(sanitizedHeaders).not.toHaveProperty('X-API-Key')
expect(sanitizedHeaders.Accept).toBe('application/json')
})

it('removes Authorization from array-format headers (HTTP agentflow node)', () => {
const headers = [
{ key: 'Authorization', value: 'Bearer secret-token' },
{ key: 'Content-Type', value: 'application/json' }
]
const flowData = makeFlowData([makeNode({ headers }, [{ name: 'headers', type: 'array' }])])
const result = JSON.parse(sanitizeFlowDataForPublicEndpoint(flowData))
const sanitizedHeaders = result.nodes[0].data.inputs.headers
expect(sanitizedHeaders).toEqual([{ key: 'Content-Type', value: 'application/json' }])
})

it('removes x-api-key case-insensitively from array-format headers', () => {
const headers = [
{ key: 'X-API-Key', value: 'secret' },
{ key: 'Accept', value: 'application/json' }
]
const flowData = makeFlowData([makeNode({ headers }, [{ name: 'headers', type: 'array' }])])
const result = JSON.parse(sanitizeFlowDataForPublicEndpoint(flowData))
const sanitizedHeaders = result.nodes[0].data.inputs.headers
expect(sanitizedHeaders).toEqual([{ key: 'Accept', value: 'application/json' }])
})

it('preserves non-sensitive string inputs unchanged', () => {
const flowData = makeFlowData([
makeNode({ temperature: '0.7', maxTokens: '1024' }, [
{ name: 'temperature', type: 'string' },
{ name: 'maxTokens', type: 'number' }
])
])
const result = JSON.parse(sanitizeFlowDataForPublicEndpoint(flowData))
expect(result.nodes[0].data.inputs.temperature).toBe('0.7')
expect(result.nodes[0].data.inputs.maxTokens).toBe('1024')
})

it('preserves startAgentflow node inputs used by the embed widget', () => {
const formInputTypes = [{ name: 'email', type: 'string', label: 'Email' }]
const flowData = makeFlowData([
makeNode(
{ startInputType: 'formInput', formTitle: 'Contact Us', formDescription: 'Fill out the form', formInputTypes },
[
{ name: 'startInputType', type: 'options' },
{ name: 'formTitle', type: 'string' },
{ name: 'formDescription', type: 'string' },
{ name: 'formInputTypes', type: 'datagrid' }
],
{ name: 'startAgentflow' }
)
])
const result = JSON.parse(sanitizeFlowDataForPublicEndpoint(flowData))
const inputs = result.nodes[0].data.inputs
expect(inputs.startInputType).toBe('formInput')
expect(inputs.formTitle).toBe('Contact Us')
expect(inputs.formDescription).toBe('Fill out the form')
expect(inputs.formInputTypes).toEqual(formInputTypes)
})

it('returns empty nodes/edges structure for malformed flowData', () => {
const result = JSON.parse(sanitizeFlowDataForPublicEndpoint('not-valid-json'))
expect(result).toEqual({ nodes: [], edges: [] })
})

it('returns the original string unchanged when flowDataString is empty', () => {
expect(sanitizeFlowDataForPublicEndpoint('')).toBe('')
})

it('does not crash when a node has no inputParams', () => {
const flowData = makeFlowData([{ id: 'n0', type: 'x', data: { inputs: { foo: 'bar' } } }])
expect(() => sanitizeFlowDataForPublicEndpoint(flowData)).not.toThrow()
const result = JSON.parse(sanitizeFlowDataForPublicEndpoint(flowData))
expect(result.nodes[0].data.inputs.foo).toBe('bar')
})
})
}
Loading