Feb 16, 2026·6 min read·61 visits
esm.sh failed to properly sanitize file paths inside NPM tarballs. Attackers can upload a package containing filenames like `../../evil.js`, causing the server to write files outside the intended directory. This can poison the CDN cache or overwrite server configs.
A critical Path Traversal vulnerability (TarSlip) in the esm.sh CDN allows attackers to escape the extraction directory when processing NPM packages. By crafting a malicious tarball with relative path sequences, an attacker can overwrite arbitrary files on the server, leading to potential Remote Code Execution (RCE) via cache poisoning or configuration tampering.
We all love esm.sh. It’s the magic wand of the modern web—no build steps, just import and go. It acts as a bridge between the chaotic world of NPM and the browser, fetching packages, transpiling them, and serving them up on a silver platter. But here's the thing about magic: it usually relies on a lot of complex plumbing behind the scenes. And in this case, the plumbing involved untrusted tarballs.
At its core, esm.sh is a glorified unzipper. It grabs a compressed file from a registry (like NPM), extracts it to a temporary directory, and processes the contents. This is a classic setup for one of the oldest tricks in the book: the TarSlip attack. If you blindly trust the filenames inside a tar archive, you're asking for trouble. It's the digital equivalent of letting a delivery driver walk into your house and place a package directly into your safe, simply because the label said "To: Safe".
CVE-2026-23644 isn't just a file write bug; it's a potential supply chain nuke. Since esm.sh caches these files to serve to millions of users, poisoning the cache means poisoning every developer and application downstream that relies on that specific package version.
The vulnerability lies in the extractPackageTarball function. The developers made a classic mistake: confusing lexical cleaning with security boundaries. In Go, path.Clean (note the missing file prefix) is designed for URL-like paths. It simplifies strings like a/b/../c into a/c. It does not, however, check if the resulting path actually stays inside a specific directory on the host's filesystem.
Here is the logic flaw in a nutshell: The code attempted to split the filename by the first slash to remove the root folder (usually package/), and then joined it with the destination directory. But what happens if the tar entry is named package/../../../../etc/passwd? The split removes package/, leaving ../../../../etc/passwd. path.Join then happily concatenates this to your pkgDir.
Because the code wasn't verifying the final resolved path against the intended root directory, the operating system (or the filepath package if it were used correctly) would interpret those .. sequences effectively, walking right out of the sandbox. The developer locked the front door but left the walls completely missing.
Let’s look at the vulnerable code pattern found in commit 9d77b88. It uses path (generic) instead of filepath (OS-aware) and relies on weak string manipulation.
The Vulnerable Code:
// Danger: blindly joining paths
_, name := utils.SplitByFirstByte(h.Name, '/')
// path.Clean purely parses the string, it doesn't resolve OS paths
filename := path.Join(pkgDir, path.Clean(name))
file, err := os.Create(filename) // OOPS. We just wrote outside pkgDir.The Fix (Commit c62ab83c589e7b421a0e1376d2a00a4e48161093):
The patch introduces a robust defense-in-depth strategy. They switched to filepath, implemented a strict normalizer, and crucially, added an allowlist for extensions.
// 1. Normalize and strip leading slashes/dots
name := utils.NormalizePathname(h.Name)[1:]
// 2. Strict Extension Allowlist (Whitelisting > Blacklisting)
// Only allow web-safe assets (.js, .css, .json, etc.)
if !assetExts[ext] && !moduleExts[ext] {
continue
}
// 3. Prevent symbolic link attacks
if h.Typeflag != tar.TypeReg {
continue
}
// 4. Secure Join
targetPath := filepath.Join(pkgDir, name)
// (Implicit check: NormalizePathname prevents '..' traversal)This fix is aggressive. By rejecting anything that isn't a regular file (no symlinks!) and enforcing strict extensions, they killed an entire class of attacks, not just the path traversal.
Exploiting this requires a bit of creativity because we want to achieve something meaningful, not just write a junk file. Since esm.sh is a CDN, our goal is Cache Poisoning.
Attack Scenario:
evil-lib. Inside, we craft a tarball manually. We don't use npm pack; we use a script to insert a file header with the name package/../../installed_pkg/index.js.evil-lib to the NPM registry (or a proxy). Then, we curl https://esm.sh/evil-lib. The server pulls our tarball and triggers the extraction logic.../ sequences traverse out of the temp directory for evil-lib and overwrite the index.js of react or lodash stored in the adjacent directory.Now, anyone requesting react from esm.sh gets our backdoor. This is supply chain poisoning at the infrastructure level.
The CVSS score of 7.7 is respectable, but the business impact is catastrophic. For a service like esm.sh, integrity is the product. If users cannot trust that the code served matches the code published on NPM, the service is dead.
Potential Consequences:
.env file or a server config file (e.g., config.json), they can point the database credentials to their own server or change execution flags.If you are running a self-hosted instance of esm.sh, you need to update immediately. The vulnerability was patched in commit c62ab83c589e7b421a0e1376d2a00a4e48161093 (Jan 16, 2026).
For Developers (The Golden Rule of Archives):
Never, ever trust the file paths inside a zip, tar, or any archive format. They are user input. Treat them with the same hostility you treat SQL parameters.
filepath.Abs(destination_dir).// The Safe Way
dest := "/tmp/safe_zone"
pathInArchive := header.Name // e.g., "../../etc/passwd"
// Join them
fullPath := filepath.Join(dest, pathInArchive)
// Check if it starts with dest
if !strings.HasPrefix(fullPath, filepath.Clean(dest) + string(os.PathSeparator)) {
panic("Hacker detected!")
}CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:H/VI:N/VA:N/SC:N/SI:N/SA:N/E:P| Product | Affected Versions | Fixed Version |
|---|---|---|
esm.sh esm-dev | < 0.0.0-20260116051925-c62ab83c589e | 0.0.0-20260116051925-c62ab83c589e |
| Attribute | Detail |
|---|---|
| CWE ID | CWE-22 (Improper Limitation of a Pathname to a Restricted Directory) |
| Attack Vector | Network |
| CVSS v4.0 | 7.7 (High) |
| EPSS Score | 0.00061 (Low Probability) |
| Impact | Arbitrary File Write / RCE |
| Exploit Status | PoC Available (TarSlip Standard) |
The software uses external input to construct a pathname that is intended to identify a file or directory that is located underneath a restricted parent directory, but the software does not properly neutralize special elements within the pathname that can cause the pathname to resolve to a location that is outside of the restricted directory.
A logic error in Hatchet's OAuth state validation mechanism allows unauthenticated remote attackers to bypass state parameter verification. By submitting an empty state parameter, attackers can exploit an equality collision with cleared session keys, facilitating Login Cross-Site Request Forgery (Login CSRF) or Session Fixation.
A high-severity access control vulnerability in ToolHive CLI before v0.30.1 and ToolHive Studio before v0.38.0 allows local containerized MCP servers to bypass network isolation. This enables malicious workloads to establish TCP/IP connections to administrative and control plane endpoints exposed on the host loopback interface.
AnyCable is a real-time communication server. Prior to version 1.6.15, its Pusher-compatible REST API suffered from an authentication bypass vulnerability because it failed to verify that the request body matched the signature-validated body_md5 parameter. This allows attackers to perform replay attacks with modified body contents.
A denial-of-service vulnerability exists in AnyIO prior to version 4.14.2. Standard error streams of process-pool workers are connected to an operating system pipe that is never drained by the parent process. This allows a worker to fill the pipe buffer and deadlock indefinitely.
CVE-2026-63349 is a critical privilege-dropping bypass vulnerability in the AnyIO asynchronous framework (versions 4.14.0 and 4.14.1) on POSIX platforms. Due to a variable assignment typo, supplementary groups specified by the developer are not correctly propagated to the execution backend, resulting in subprocesses retaining the parent process's elevated supplementary group permissions.
CVE-2026-63406 is an information disclosure vulnerability in AnyCable-go prior to version 1.6.15. The built-in telemetry client is enabled by default with a hardcoded public authentication token ('secret'). This client digests highly sensitive configuration parameters and command-line arguments, including JWT secrets and RPC secrets, into a stable SHA-256 fingerprint. This fingerprint is sent over public networks, exposing those administrative secrets to offline dictionary and brute-force attacks if intercepted.