Skip to content

Commit 7291ab8

Browse files
christopherholland-workdaychristopherholland-workday
andauthored
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 <christopher.holland+evisort@workday.com>
1 parent cb691eb commit 7291ab8

4 files changed

Lines changed: 239 additions & 2 deletions

File tree

packages/server/src/services/chatflows/index.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import documentStoreService from '../../services/documentstore'
1717
import { constructGraphs, getAppVersion, getEndingNodes, getTelemetryFlowObj, isFlowValidForStream } from '../../utils'
1818
import { sanitizeAllowedUploadMimeTypesFromConfig } from '../../utils/fileValidation'
1919
import { containsBase64File, updateFlowDataWithFilePaths } from '../../utils/fileRepository'
20+
import { sanitizeFlowDataForPublicEndpoint } from '../../utils/sanitizeFlowData'
2021
import { getRunningExpressApp } from '../../utils/getRunningExpressApp'
2122
import { utilGetUploadsConfig } from '../../utils/getUploadsConfig'
2223
import logger from '../../utils/logger'
@@ -375,7 +376,7 @@ const updateChatflow = async (
375376
}
376377

377378
// Get specific chatflow chatbotConfig via id (PUBLIC endpoint, used to retrieve config for embedded chat)
378-
// Safe as public endpoint as chatbotConfig doesn't contain sensitive credential
379+
// flowData is sanitized before returning — password, file, folder inputs and credential references are stripped
379380
const getSinglePublicChatbotConfig = async (chatflowId: string): Promise<any> => {
380381
try {
381382
const appServer = getRunningExpressApp()
@@ -404,7 +405,12 @@ const getSinglePublicChatbotConfig = async (chatflowId: string): Promise<any> =>
404405
}
405406
delete parsedConfig.allowedOrigins
406407
delete parsedConfig.allowedOriginsError
407-
return { ...parsedConfig, uploads: uploadsConfig, flowData: dbResponse.flowData, isTTSEnabled }
408+
return {
409+
...parsedConfig,
410+
uploads: uploadsConfig,
411+
flowData: sanitizeFlowDataForPublicEndpoint(dbResponse.flowData),
412+
isTTSEnabled
413+
}
408414
} catch (e) {
409415
throw new InternalFlowiseError(StatusCodes.INTERNAL_SERVER_ERROR, `Error parsing Chatbot Config for Chatflow ${chatflowId}`)
410416
}
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
import { INodeParams } from 'flowise-components'
2+
import { IReactFlowObject } from '../Interface'
3+
4+
const SENSITIVE_HEADER_KEYS = new Set(['authorization', 'x-api-key', 'x-auth-token', 'cookie'])
5+
6+
/**
7+
* Sanitizes flowData before returning it from a public endpoint.
8+
* Strips password/file/folder inputs, credential ID references, and
9+
* auth-related HTTP headers so sensitive credentials are never exposed.
10+
*/
11+
export const sanitizeFlowDataForPublicEndpoint = (flowDataString: string): string => {
12+
if (!flowDataString) return flowDataString
13+
try {
14+
const flowData: IReactFlowObject = JSON.parse(flowDataString)
15+
if (!Array.isArray(flowData.nodes)) return flowDataString
16+
17+
for (const node of flowData.nodes) {
18+
if (!node.data) continue
19+
20+
// Remove credential ID reference
21+
delete node.data.credential
22+
23+
const inputs = node.data.inputs
24+
const inputParams: INodeParams[] = node.data.inputParams
25+
26+
if (!inputs || !inputParams) continue
27+
28+
const sanitizedInputs: Record<string, unknown> = {}
29+
for (const key of Object.keys(inputs)) {
30+
const param = inputParams.find((p) => p.name === key)
31+
32+
if (param && (param.type === 'password' || param.type === 'file' || param.type === 'folder')) {
33+
continue
34+
}
35+
36+
if (key === 'headers' && inputs[key]) {
37+
try {
38+
const rawHeaders = inputs[key]
39+
// Array format: [{ key: string, value: string }, ...] (e.g. HTTP agentflow node)
40+
if (Array.isArray(rawHeaders)) {
41+
sanitizedInputs[key] = rawHeaders.filter(
42+
(h: { key?: string; value?: string }) => !h.key || !SENSITIVE_HEADER_KEYS.has(h.key.toLowerCase())
43+
)
44+
continue
45+
}
46+
// Object/string format: Record<string, string> or JSON string thereof
47+
const headers: Record<string, string> =
48+
typeof rawHeaders === 'string' ? JSON.parse(rawHeaders) : { ...(rawHeaders as object) }
49+
for (const h of Object.keys(headers)) {
50+
if (SENSITIVE_HEADER_KEYS.has(h.toLowerCase())) delete headers[h]
51+
}
52+
sanitizedInputs[key] = typeof rawHeaders === 'string' ? JSON.stringify(headers) : headers
53+
continue
54+
} catch {
55+
// Drop headers that cannot be parsed
56+
continue
57+
}
58+
}
59+
60+
sanitizedInputs[key] = inputs[key]
61+
}
62+
node.data.inputs = sanitizedInputs
63+
}
64+
65+
return JSON.stringify(flowData)
66+
} catch {
67+
return JSON.stringify({ nodes: [], edges: [] })
68+
}
69+
}

packages/server/test/index.test.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { getRunningExpressApp } from '../src/utils/getRunningExpressApp'
33
import { organizationUserRouteTest } from './routes/v1/organization-user.route.test'
44
import { userRouteTest } from './routes/v1/user.route.test'
55
import { apiKeyTest } from './utils/api-key.util.test'
6+
import { sanitizeFlowDataTest } from './utils/sanitizeFlowData.test'
67

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

2627
describe('Utils Test', () => {
2728
apiKeyTest()
29+
sanitizeFlowDataTest()
2830
})
Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,160 @@
1+
import { sanitizeFlowDataForPublicEndpoint } from '../../src/utils/sanitizeFlowData'
2+
3+
const makeFlowData = (nodes: object[], edges: object[] = []) => JSON.stringify({ nodes, edges, viewport: { x: 0, y: 0, zoom: 1 } })
4+
5+
const makeNode = (inputs: Record<string, unknown>, inputParams: object[], extra: object = {}) => ({
6+
id: 'node_0',
7+
position: { x: 0, y: 0 },
8+
type: 'customNode',
9+
data: {
10+
id: 'node_0',
11+
name: 'testNode',
12+
label: 'Test Node',
13+
inputs,
14+
inputParams,
15+
...extra
16+
}
17+
})
18+
19+
export function sanitizeFlowDataTest() {
20+
describe('sanitizeFlowDataForPublicEndpoint', () => {
21+
it('strips password-type inputs', () => {
22+
const flowData = makeFlowData([
23+
makeNode({ apiKey: 'sk-secret', model: 'gpt-4' }, [
24+
{ name: 'apiKey', type: 'password' },
25+
{ name: 'model', type: 'string' }
26+
])
27+
])
28+
const result = JSON.parse(sanitizeFlowDataForPublicEndpoint(flowData))
29+
expect(result.nodes[0].data.inputs).not.toHaveProperty('apiKey')
30+
expect(result.nodes[0].data.inputs.model).toBe('gpt-4')
31+
})
32+
33+
it('strips file-type inputs', () => {
34+
const flowData = makeFlowData([
35+
makeNode({ filePath: '/data/secret.pdf', label: 'loader' }, [
36+
{ name: 'filePath', type: 'file' },
37+
{ name: 'label', type: 'string' }
38+
])
39+
])
40+
const result = JSON.parse(sanitizeFlowDataForPublicEndpoint(flowData))
41+
expect(result.nodes[0].data.inputs).not.toHaveProperty('filePath')
42+
expect(result.nodes[0].data.inputs.label).toBe('loader')
43+
})
44+
45+
it('strips folder-type inputs', () => {
46+
const flowData = makeFlowData([
47+
makeNode({ folderPath: '/home/user/docs', name: 'ingest' }, [
48+
{ name: 'folderPath', type: 'folder' },
49+
{ name: 'name', type: 'string' }
50+
])
51+
])
52+
const result = JSON.parse(sanitizeFlowDataForPublicEndpoint(flowData))
53+
expect(result.nodes[0].data.inputs).not.toHaveProperty('folderPath')
54+
expect(result.nodes[0].data.inputs.name).toBe('ingest')
55+
})
56+
57+
it('removes credential field from node data', () => {
58+
const flowData = makeFlowData([
59+
makeNode({ model: 'gpt-4' }, [{ name: 'model', type: 'string' }], { credential: 'cred-uuid-123' })
60+
])
61+
const result = JSON.parse(sanitizeFlowDataForPublicEndpoint(flowData))
62+
expect(result.nodes[0].data).not.toHaveProperty('credential')
63+
})
64+
65+
it('removes Authorization header from headers input, preserves other headers', () => {
66+
const headers = JSON.stringify({ Authorization: 'Bearer secret-token', 'Content-Type': 'application/json' })
67+
const flowData = makeFlowData([
68+
makeNode({ headers, url: 'https://example.com' }, [
69+
{ name: 'headers', type: 'json' },
70+
{ name: 'url', type: 'string' }
71+
])
72+
])
73+
const result = JSON.parse(sanitizeFlowDataForPublicEndpoint(flowData))
74+
const sanitizedHeaders = JSON.parse(result.nodes[0].data.inputs.headers)
75+
expect(sanitizedHeaders).not.toHaveProperty('Authorization')
76+
expect(sanitizedHeaders['Content-Type']).toBe('application/json')
77+
})
78+
79+
it('removes x-api-key header case-insensitively', () => {
80+
const headers = JSON.stringify({ 'X-API-Key': 'my-key', Accept: 'application/json' })
81+
const flowData = makeFlowData([makeNode({ headers }, [{ name: 'headers', type: 'json' }])])
82+
const result = JSON.parse(sanitizeFlowDataForPublicEndpoint(flowData))
83+
const sanitizedHeaders = JSON.parse(result.nodes[0].data.inputs.headers)
84+
expect(sanitizedHeaders).not.toHaveProperty('X-API-Key')
85+
expect(sanitizedHeaders.Accept).toBe('application/json')
86+
})
87+
88+
it('removes Authorization from array-format headers (HTTP agentflow node)', () => {
89+
const headers = [
90+
{ key: 'Authorization', value: 'Bearer secret-token' },
91+
{ key: 'Content-Type', value: 'application/json' }
92+
]
93+
const flowData = makeFlowData([makeNode({ headers }, [{ name: 'headers', type: 'array' }])])
94+
const result = JSON.parse(sanitizeFlowDataForPublicEndpoint(flowData))
95+
const sanitizedHeaders = result.nodes[0].data.inputs.headers
96+
expect(sanitizedHeaders).toEqual([{ key: 'Content-Type', value: 'application/json' }])
97+
})
98+
99+
it('removes x-api-key case-insensitively from array-format headers', () => {
100+
const headers = [
101+
{ key: 'X-API-Key', value: 'secret' },
102+
{ key: 'Accept', value: 'application/json' }
103+
]
104+
const flowData = makeFlowData([makeNode({ headers }, [{ name: 'headers', type: 'array' }])])
105+
const result = JSON.parse(sanitizeFlowDataForPublicEndpoint(flowData))
106+
const sanitizedHeaders = result.nodes[0].data.inputs.headers
107+
expect(sanitizedHeaders).toEqual([{ key: 'Accept', value: 'application/json' }])
108+
})
109+
110+
it('preserves non-sensitive string inputs unchanged', () => {
111+
const flowData = makeFlowData([
112+
makeNode({ temperature: '0.7', maxTokens: '1024' }, [
113+
{ name: 'temperature', type: 'string' },
114+
{ name: 'maxTokens', type: 'number' }
115+
])
116+
])
117+
const result = JSON.parse(sanitizeFlowDataForPublicEndpoint(flowData))
118+
expect(result.nodes[0].data.inputs.temperature).toBe('0.7')
119+
expect(result.nodes[0].data.inputs.maxTokens).toBe('1024')
120+
})
121+
122+
it('preserves startAgentflow node inputs used by the embed widget', () => {
123+
const formInputTypes = [{ name: 'email', type: 'string', label: 'Email' }]
124+
const flowData = makeFlowData([
125+
makeNode(
126+
{ startInputType: 'formInput', formTitle: 'Contact Us', formDescription: 'Fill out the form', formInputTypes },
127+
[
128+
{ name: 'startInputType', type: 'options' },
129+
{ name: 'formTitle', type: 'string' },
130+
{ name: 'formDescription', type: 'string' },
131+
{ name: 'formInputTypes', type: 'datagrid' }
132+
],
133+
{ name: 'startAgentflow' }
134+
)
135+
])
136+
const result = JSON.parse(sanitizeFlowDataForPublicEndpoint(flowData))
137+
const inputs = result.nodes[0].data.inputs
138+
expect(inputs.startInputType).toBe('formInput')
139+
expect(inputs.formTitle).toBe('Contact Us')
140+
expect(inputs.formDescription).toBe('Fill out the form')
141+
expect(inputs.formInputTypes).toEqual(formInputTypes)
142+
})
143+
144+
it('returns empty nodes/edges structure for malformed flowData', () => {
145+
const result = JSON.parse(sanitizeFlowDataForPublicEndpoint('not-valid-json'))
146+
expect(result).toEqual({ nodes: [], edges: [] })
147+
})
148+
149+
it('returns the original string unchanged when flowDataString is empty', () => {
150+
expect(sanitizeFlowDataForPublicEndpoint('')).toBe('')
151+
})
152+
153+
it('does not crash when a node has no inputParams', () => {
154+
const flowData = makeFlowData([{ id: 'n0', type: 'x', data: { inputs: { foo: 'bar' } } }])
155+
expect(() => sanitizeFlowDataForPublicEndpoint(flowData)).not.toThrow()
156+
const result = JSON.parse(sanitizeFlowDataForPublicEndpoint(flowData))
157+
expect(result.nodes[0].data.inputs.foo).toBe('bar')
158+
})
159+
})
160+
}

0 commit comments

Comments
 (0)