Skip to content

Commit d76b15d

Browse files
committed
Merge branch 'main' into feat/agentflow-create-credential
2 parents 251b742 + c93d6c0 commit d76b15d

9 files changed

Lines changed: 189 additions & 25 deletions

File tree

packages/agentflow/src/infrastructure/api/credentials.test.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,16 @@ describe('bindCredentialsApi', () => {
3333
expect(result).toEqual(mockCredentials)
3434
})
3535

36+
it('should not expose encryptedData in the response', async () => {
37+
const mockCredentials = [{ id: '1', name: 'My OpenAI Key', credentialName: 'openAIApi' }]
38+
;(mockClient.get as jest.Mock).mockResolvedValue({ data: mockCredentials })
39+
40+
const result = await api.getCredentialsByName('openAIApi')
41+
for (const credential of result) {
42+
expect(credential).not.toHaveProperty('encryptedData')
43+
}
44+
})
45+
3646
it('getComponentCredentialSchema calls GET /components-credentials/:name', async () => {
3747
const mockSchema = { name: 'openAIApi', label: 'OpenAI API', inputs: [] }
3848
;(mockClient.get as jest.Mock).mockResolvedValue({ data: mockSchema })

packages/components/nodes/tools/MCP/core.test.ts

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,22 @@ describe('MCP Security Validations', () => {
3333
}).toThrow("Argument '-y' is not allowed for command 'npx'")
3434
})
3535

36+
it('should block --yes flag', () => {
37+
expect(() => {
38+
validateCommandFlags('npx', ['--yes', 'https://test-malicious-download.com'])
39+
}).toThrow("Argument '--yes' is not allowed for command 'npx'")
40+
})
41+
42+
it('should block --node-options flag', () => {
43+
expect(() => {
44+
validateCommandFlags('npx', ['--node-options', '--eval malicious'])
45+
}).toThrow("Argument '--node-options' is not allowed for command 'npx'")
46+
47+
expect(() => {
48+
validateCommandFlags('npx', ['--node-options=--eval malicious'])
49+
}).toThrow("contains flag '--node-options'")
50+
})
51+
3652
it('should block case variations', () => {
3753
expect(() => {
3854
validateCommandFlags('npx', ['-C', 'command'])
@@ -83,6 +99,42 @@ describe('MCP Security Validations', () => {
8399
}).toThrow("Argument '--inspect-brk' is not allowed for command 'node'")
84100
})
85101

102+
it('should block -r/--require flags', () => {
103+
expect(() => {
104+
validateCommandFlags('node', ['-r', 'malicious-module'])
105+
}).toThrow("Argument '-r' is not allowed for command 'node'")
106+
107+
expect(() => {
108+
validateCommandFlags('node', ['--require', 'malicious-module'])
109+
}).toThrow("Argument '--require' is not allowed for command 'node'")
110+
})
111+
112+
it('should block --loader/--experimental-loader flags', () => {
113+
expect(() => {
114+
validateCommandFlags('node', ['--loader', './malicious-loader.mjs'])
115+
}).toThrow("Argument '--loader' is not allowed for command 'node'")
116+
117+
expect(() => {
118+
validateCommandFlags('node', ['--experimental-loader', './malicious-loader.mjs'])
119+
}).toThrow("Argument '--experimental-loader' is not allowed for command 'node'")
120+
})
121+
122+
it('should block --import flag', () => {
123+
expect(() => {
124+
validateCommandFlags('node', ['--import', './malicious.mjs'])
125+
}).toThrow("Argument '--import' is not allowed for command 'node'")
126+
})
127+
128+
it('should block --env-file flag', () => {
129+
expect(() => {
130+
validateCommandFlags('node', ['--env-file', '.env'])
131+
}).toThrow("Argument '--env-file' is not allowed for command 'node'")
132+
133+
expect(() => {
134+
validateCommandFlags('node', ['--env-file=.env'])
135+
}).toThrow("contains flag '--env-file'")
136+
})
137+
86138
it('should allow legitimate node usage', () => {
87139
expect(() => {
88140
validateCommandFlags('node', ['server.js'])
@@ -189,6 +241,56 @@ describe('MCP Security Validations', () => {
189241
}).toThrow("Argument '--ipc' is not allowed for command 'docker'")
190242
})
191243

244+
it('should block --mount flag', () => {
245+
expect(() => {
246+
validateCommandFlags('docker', ['--mount', 'type=bind,source=/,target=/host'])
247+
}).toThrow("Argument '--mount' is not allowed for command 'docker'")
248+
249+
expect(() => {
250+
validateCommandFlags('docker', ['--mount=type=bind,source=/,target=/host'])
251+
}).toThrow("contains flag '--mount'")
252+
})
253+
254+
it('should block --device flag', () => {
255+
expect(() => {
256+
validateCommandFlags('docker', ['--device', '/dev/sda'])
257+
}).toThrow("Argument '--device' is not allowed for command 'docker'")
258+
})
259+
260+
it('should block --entrypoint flag', () => {
261+
expect(() => {
262+
validateCommandFlags('docker', ['--entrypoint', '/bin/sh'])
263+
}).toThrow("Argument '--entrypoint' is not allowed for command 'docker'")
264+
})
265+
266+
it('should block compose subcommand', () => {
267+
expect(() => {
268+
validateCommandFlags('docker', ['compose', 'up'])
269+
}).toThrow("Argument 'compose' is not allowed for command 'docker'")
270+
})
271+
272+
it('should block --volumes-from flag', () => {
273+
expect(() => {
274+
validateCommandFlags('docker', ['--volumes-from', 'other-container'])
275+
}).toThrow("Argument '--volumes-from' is not allowed for command 'docker'")
276+
})
277+
278+
it('should block --env-file flag', () => {
279+
expect(() => {
280+
validateCommandFlags('docker', ['--env-file', '/etc/secrets'])
281+
}).toThrow("Argument '--env-file' is not allowed for command 'docker'")
282+
283+
expect(() => {
284+
validateCommandFlags('docker', ['--env-file=/etc/secrets'])
285+
}).toThrow("contains flag '--env-file'")
286+
})
287+
288+
it('should block build subcommand', () => {
289+
expect(() => {
290+
validateCommandFlags('docker', ['build', 'https://evil.com/'])
291+
}).toThrow("Argument 'build' is not allowed for command 'docker'")
292+
})
293+
192294
it('should allow safe docker usage', () => {
193295
expect(() => {
194296
validateCommandFlags('docker', ['ps'])
@@ -271,6 +373,12 @@ describe('MCP Security Validations', () => {
271373
}).toThrow('Argument contains potential local file access')
272374
})
273375

376+
it('should block double-slash absolute paths', () => {
377+
expect(() => {
378+
validateArgsForLocalFileAccess(['//etc/passwd'])
379+
}).toThrow('Argument contains potential local file access')
380+
})
381+
274382
it('should block path traversal', () => {
275383
expect(() => {
276384
validateArgsForLocalFileAccess(['../../../etc/passwd'])

packages/components/nodes/tools/MCP/core.ts

Lines changed: 35 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -15,14 +15,19 @@ export class MCPToolkit extends BaseToolkit {
1515
client: Client | null = null
1616
serverParams: StdioServerParameters | any
1717
transportType: 'stdio' | 'sse'
18+
/** Per-invocation HTTP headers injected at tools/call time; overrides static toolkit headers for the same names. */
19+
getToolCallHeaders?: () => Promise<Record<string, string>>
1820
constructor(serverParams: StdioServerParameters | any, transportType: 'stdio' | 'sse') {
1921
super()
2022
this.serverParams = serverParams
2123
this.transportType = transportType
2224
}
2325

24-
// Method to create a new client with transport
25-
async createClient(): Promise<Client> {
26+
/**
27+
* Creates a new MCP client and connects it via the configured transport.
28+
* @param injectHeaders - Additional HTTP headers merged over static `serverParams.headers` for this connection. Used to pass per-invocation headers (e.g. from {@link getToolCallHeaders}) into SSE/HTTP transports.
29+
*/
30+
async createClient(injectHeaders: Record<string, string> = {}): Promise<Client> {
2631
const client = new Client(
2732
{
2833
name: 'flowise-client',
@@ -54,28 +59,30 @@ export class MCPToolkit extends BaseToolkit {
5459

5560
const baseUrl = new URL(this.serverParams.url)
5661
await checkDenyList(this.serverParams.url)
62+
const mergedHeaders = { ...this.serverParams?.headers, ...injectHeaders }
63+
const headers = Object.keys(mergedHeaders).length > 0 ? mergedHeaders : undefined
5764
try {
58-
if (this.serverParams.headers) {
65+
if (headers) {
5966
transport = new StreamableHTTPClientTransport(baseUrl, {
6067
requestInit: {
61-
headers: this.serverParams.headers
68+
headers
6269
}
6370
})
6471
} else {
6572
transport = new StreamableHTTPClientTransport(baseUrl)
6673
}
6774
await client.connect(transport)
6875
} catch (error) {
69-
if (this.serverParams.headers) {
76+
if (headers) {
7077
transport = new SSEClientTransport(baseUrl, {
7178
requestInit: {
72-
headers: this.serverParams.headers
79+
headers
7380
},
7481
eventSourceInit: {
7582
fetch: async (url, init) => {
7683
return secureFetch(url.toString(), {
7784
...(init as any),
78-
headers: this.serverParams.headers
85+
headers
7986
}) as any
8087
}
8188
}
@@ -148,7 +155,8 @@ export async function MCPTool({
148155
return tool(
149156
async (input): Promise<string> => {
150157
// Create a new client for this request
151-
const client = await toolkit.createClient()
158+
const toolCallHeaders = await toolkit.getToolCallHeaders?.()
159+
const client = await toolkit.createClient(toolCallHeaders)
152160

153161
try {
154162
const req: CallToolRequest = { method: 'tools/call', params: { name: name, arguments: input as any } }
@@ -190,7 +198,7 @@ function createSchemaModel(
190198
export const validateArgsForLocalFileAccess = (args: string[]): void => {
191199
const dangerousPatterns = [
192200
// Absolute paths
193-
/^\/[^/]/, // Unix absolute paths starting with /
201+
/^\//, // Unix absolute paths starting with /
194202
/^[a-zA-Z]:\\/, // Windows absolute paths like C:\
195203

196204
// Relative paths that could escape current directory
@@ -286,7 +294,9 @@ export const validateCommandFlags = (command: string, args: string[]): void => {
286294
'-c', // Execute shell commands
287295
'--call', // Execute shell commands
288296
'--shell-auto-fallback', // Shell execution fallback
289-
'-y' // Auto-confirms installation prompts
297+
'-y', // Auto-confirms installation prompts
298+
'--yes', // Auto-confirms installation prompts
299+
'--node-options' // Passes arbitrary Node flags to underlying process, bypassing node flag blocklist
290300
],
291301
node: [
292302
'-e', // Execute JavaScript code
@@ -295,7 +305,13 @@ export const validateCommandFlags = (command: string, args: string[]): void => {
295305
'--print', // Evaluate and print JavaScript code
296306
'--inspect', // Enable remote debugging (security risk)
297307
'--inspect-brk', // Enable remote debugging with breakpoint (security risk)
298-
'--experimental-policy' // Could load malicious policies
308+
'--experimental-policy', // Could load malicious policies
309+
'-r', // Short alias for --require
310+
'--require', // Preload a CommonJS module before script runs
311+
'--loader', // Custom ES module loader hook (code execution)
312+
'--experimental-loader', // Same as --loader, older Node alias
313+
'--import', // Preload ESM module before entry script (Node 18+)
314+
'--env-file' // Read env vars from a local file (Node 20+, local file access)
299315
],
300316
python: [
301317
'-c', // Execute Python code
@@ -307,15 +323,22 @@ export const validateCommandFlags = (command: string, args: string[]): void => {
307323
],
308324
docker: [
309325
'run', // Run containers (too powerful)
326+
'build', // Pulls a container and executes the run instructions
310327
'exec', // Execute in containers
328+
'compose', // Subcommand that starts containers (same risk as run)
311329
'-v', // Mount host filesystems
312330
'--volume', // Mount host filesystems
331+
'--mount', // Alternative to -v/--volume for mounting host paths
332+
'--volumes-from', // Mount volumes from another container (filesystem access)
313333
'--privileged', // Privileged mode
314334
'--cap-add', // Add capabilities
315335
'--security-opt', // Modify security options
336+
'--device', // Add host device files to container (privilege escalation)
337+
'--entrypoint', // Override container entrypoint (arbitrary code execution)
316338
'--network', // Host network access (catches --network=host and --network host)
317339
'--pid', // Host PID namespace (catches --pid=host and --pid host)
318-
'--ipc' // Host IPC namespace (catches --ipc=host and --ipc host)
340+
'--ipc', // Host IPC namespace (catches --ipc=host and --ipc host)
341+
'--env-file' // Read env vars from a local host file (local file access)
319342
]
320343
}
321344

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -99,7 +99,7 @@ const getChatflowByApiKey = async (req: Request, res: Response, next: NextFuncti
9999
if (!apikey) {
100100
return res.status(401).send('Unauthorized')
101101
}
102-
const apiResponse = await chatflowsService.getChatflowByApiKey(apikey.id, req.query.keyonly)
102+
const apiResponse = await chatflowsService.getChatflowByApiKey(apikey.id, apikey.workspaceId, req.query.keyonly)
103103
return res.json(apiResponse)
104104
} catch (error) {
105105
next(error)

packages/server/src/controllers/tools/index.ts

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,9 +18,17 @@ const createTool = async (req: Request, res: Response, next: NextFunction) => {
1818
throw new InternalFlowiseError(StatusCodes.NOT_FOUND, `Error: toolsController.createTool - workspace ${workspaceId} not found!`)
1919
}
2020
const body = req.body
21-
body.workspaceId = workspaceId
21+
// Explicit allowlist — id/workspaceId/timestamps must not be overrideable by client
22+
const toolBody: Record<string, unknown> = {}
23+
if (body.name !== undefined) toolBody.name = body.name
24+
if (body.description !== undefined) toolBody.description = body.description
25+
if (body.color !== undefined) toolBody.color = body.color
26+
if (body.iconSrc !== undefined) toolBody.iconSrc = body.iconSrc
27+
if (body.schema !== undefined) toolBody.schema = body.schema
28+
if (body.func !== undefined) toolBody.func = body.func
29+
toolBody.workspaceId = workspaceId
2230

23-
const apiResponse = await toolsService.createTool(body, orgId)
31+
const apiResponse = await toolsService.createTool(toolBody, orgId)
2432
return res.json(apiResponse)
2533
} catch (error) {
2634
next(error)
@@ -84,7 +92,16 @@ const updateTool = async (req: Request, res: Response, next: NextFunction) => {
8492
if (!workspaceId) {
8593
throw new InternalFlowiseError(StatusCodes.NOT_FOUND, `Error: toolsController.updateTool - workspace ${workspaceId} not found!`)
8694
}
87-
const apiResponse = await toolsService.updateTool(req.params.id, req.body, workspaceId)
95+
const body = req.body
96+
// Explicit allowlist — id/workspaceId/timestamps must not be overrideable by client
97+
const toolBody: Record<string, unknown> = {}
98+
if (body.name !== undefined) toolBody.name = body.name
99+
if (body.description !== undefined) toolBody.description = body.description
100+
if (body.color !== undefined) toolBody.color = body.color
101+
if (body.iconSrc !== undefined) toolBody.iconSrc = body.iconSrc
102+
if (body.schema !== undefined) toolBody.schema = body.schema
103+
if (body.func !== undefined) toolBody.func = body.func
104+
const apiResponse = await toolsService.updateTool(req.params.id, toolBody, workspaceId)
88105
return res.json(apiResponse)
89106
} catch (error) {
90107
next(error)

packages/server/src/enterprise/middleware/passport/SessionPersistance.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -163,7 +163,7 @@ export const destroyAllSessionsForUser = async (userId: string): Promise<void> =
163163
await repository
164164
.createQueryBuilder()
165165
.delete()
166-
.where(`JSON_EXTRACT(sess, '$.passport.user.id') = :userId`, { userId })
166+
.where(`JSON_EXTRACT(data, '$.passport.user.id') = :userId`, { userId }) // express-mysql-session uses column name 'data' for session payload, not 'sess'
167167
.execute()
168168
break
169169
case 'postgres':

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

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { ICommonObject, removeFolderFromStorage } from 'flowise-components'
22
import { StatusCodes } from 'http-status-codes'
3-
import { In } from 'typeorm'
3+
import { Brackets, In } from 'typeorm'
44
import { validate as isValidUUID } from 'uuid'
55
import { ChatflowType, IReactFlowObject } from '../../Interface'
66
import { FLOWISE_COUNTER_STATUS, FLOWISE_METRIC_COUNTERS } from '../../Interface.Metrics'
@@ -220,16 +220,21 @@ const getAllChatflowsCount = async (type?: ChatflowType, workspaceId?: string):
220220
}
221221
}
222222

223-
const getChatflowByApiKey = async (apiKeyId: string, keyonly?: unknown): Promise<any> => {
223+
const getChatflowByApiKey = async (apiKeyId: string, workspaceId: string, keyonly?: unknown): Promise<any> => {
224224
try {
225225
// Here we only get chatflows that are bounded by the apikeyid and chatflows that are not bounded by any apikey
226226
const appServer = getRunningExpressApp()
227227
let query = appServer.AppDataSource.getRepository(ChatFlow)
228228
.createQueryBuilder('cf')
229-
.where('cf.apikeyid = :apikeyid', { apikeyid: apiKeyId })
230-
if (keyonly === undefined) {
231-
query = query.orWhere('cf.apikeyid IS NULL').orWhere('cf.apikeyid = ""')
232-
}
229+
.where('cf.workspaceId = :workspaceId', { workspaceId })
230+
.andWhere(
231+
new Brackets((qb) => {
232+
qb.where('cf.apikeyid = :apikeyid', { apikeyid: apiKeyId })
233+
if (keyonly === undefined) {
234+
qb.orWhere('cf.apikeyid IS NULL').orWhere('cf.apikeyid = ""')
235+
}
236+
})
237+
)
233238

234239
const dbResponse = await query.orderBy('cf.name', 'ASC').getMany()
235240
if (dbResponse.length < 1) {

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -60,15 +60,15 @@ const getAllCredentials = async (paramCredentialName: any, workspaceId: string)
6060
...getWorkspaceSearchOptions(workspaceId)
6161
}
6262
const credentials = await appServer.AppDataSource.getRepository(Credential).findBy(searchOptions)
63-
dbResponse.push(...credentials)
63+
dbResponse.push(...credentials.map((c) => omit(c, ['encryptedData'])))
6464
}
6565
} else {
6666
const searchOptions = {
6767
credentialName: paramCredentialName,
6868
...getWorkspaceSearchOptions(workspaceId)
6969
}
7070
const credentials = await appServer.AppDataSource.getRepository(Credential).findBy(searchOptions)
71-
dbResponse = [...credentials]
71+
dbResponse = credentials.map((c) => omit(c, ['encryptedData']))
7272
}
7373
// get shared credentials
7474
if (workspaceId) {

0 commit comments

Comments
 (0)