From 7291ab83b82b2aaae864da507179234abd6d644a Mon Sep 17 00:00:00 2001 From: christopherholland-workday Date: Thu, 26 Feb 2026 09:01:11 -0800 Subject: [PATCH] Remove flowData from getSinglePublicChatbotConfig response (#5751) * Remove flowData from getSinglePublicChatbotConfig response * Remove flowData from getSinglePublicChatbotConfig response * Sanitize flowData to prevent sensitive data exposure --------- Co-authored-by: christopherholland-workday --- .../server/src/services/chatflows/index.ts | 10 +- packages/server/src/utils/sanitizeFlowData.ts | 69 ++++++++ packages/server/test/index.test.ts | 2 + .../test/utils/sanitizeFlowData.test.ts | 160 ++++++++++++++++++ 4 files changed, 239 insertions(+), 2 deletions(-) create mode 100644 packages/server/src/utils/sanitizeFlowData.ts create mode 100644 packages/server/test/utils/sanitizeFlowData.test.ts diff --git a/packages/server/src/services/chatflows/index.ts b/packages/server/src/services/chatflows/index.ts index e28fe278bb9..9998ca57643 100644 --- a/packages/server/src/services/chatflows/index.ts +++ b/packages/server/src/services/chatflows/index.ts @@ -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' @@ -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 => { try { const appServer = getRunningExpressApp() @@ -404,7 +405,12 @@ const getSinglePublicChatbotConfig = async (chatflowId: string): Promise => } 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}`) } diff --git a/packages/server/src/utils/sanitizeFlowData.ts b/packages/server/src/utils/sanitizeFlowData.ts new file mode 100644 index 00000000000..d00f7ef996f --- /dev/null +++ b/packages/server/src/utils/sanitizeFlowData.ts @@ -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 = {} + 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 or JSON string thereof + const headers: Record = + 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: [] }) + } +} diff --git a/packages/server/test/index.test.ts b/packages/server/test/index.test.ts index 8c038f44f62..04778fe2a80 100644 --- a/packages/server/test/index.test.ts +++ b/packages/server/test/index.test.ts @@ -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) @@ -25,4 +26,5 @@ describe('Routes Test', () => { describe('Utils Test', () => { apiKeyTest() + sanitizeFlowDataTest() }) diff --git a/packages/server/test/utils/sanitizeFlowData.test.ts b/packages/server/test/utils/sanitizeFlowData.test.ts new file mode 100644 index 00000000000..fd1a6a83341 --- /dev/null +++ b/packages/server/test/utils/sanitizeFlowData.test.ts @@ -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, 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') + }) + }) +}