Feb 16, 2026·6 min read·4 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.