VYPR
High severityOSV Advisory· Published Jan 18, 2026· Updated Jan 20, 2026

esm.sh has path traversal in `extractPackageTarball` that enables file writes from malicious packages

CVE-2026-23644

Description

esm.sh is a no-build content delivery network (CDN) for web development. Prior to Go pseeudoversion 0.0.0-20260116051925-c62ab83c589e, the software has a path traversal vulnerability due to an incomplete fix. path.Clean normalizes a path but does not prevent absolute paths in a malicious tar file. Commit https://github.com/esm-dev/esm.sh/commit/9d77b88c320733ff6689d938d85d246a3af9af16, corresponding to pseudoversion 0.0.0-20260116051925-c62ab83c589e, fixes this issue.

Affected packages

Versions sourced from the GitHub Security Advisory.

PackageAffected versionsPatched versions
github.com/esm-dev/esm.shGo
>= 0.0.1, <= 136
github.com/esm-dev/esm.shGo
< 0.0.0-20260116051925-c62ab83c589e0.0.0-20260116051925-c62ab83c589e

Affected products

1

Patches

2
c62ab83c589e

Fix path traversal vulnerability in `extractPackageTarball` function (#1287)

https://github.com/esm-dev/esm.shJe XiaJan 16, 2026via ghsa
2 files changed · +125 21
  • server/npmrc.go+24 21 modified
    @@ -13,7 +13,7 @@ import (
     	"net/http"
     	"net/url"
     	"os"
    -	"path"
    +	"path/filepath"
     	"slices"
     	"sort"
     	"strings"
    @@ -109,7 +109,7 @@ func NewNpmRcFromJSON(jsonData []byte) (npmrc *NpmRC, err error) {
     }
     
     func (rc *NpmRC) StoreDir() string {
    -	return path.Join(config.WorkDir, "npm")
    +	return filepath.Join(config.WorkDir, "npm")
     }
     
     func (npmrc *NpmRC) getRegistryByPackageName(packageName string) *NpmRegistry {
    @@ -243,7 +243,7 @@ func (npmrc *NpmRC) getPackageInfo(pkgName string, version string) (packageJson
     	return withCache(getCacheKey(pkgName, version), time.Duration(config.NpmQueryCacheTTL)*time.Second, func() (*npm.PackageJSON, string, error) {
     		if !npm.IsDistTag(version) && npm.IsExactVersion(version) {
     			var raw npm.PackageJSONRaw
    -			pkgJsonPath := path.Join(npmrc.StoreDir(), pkgName+"@"+version, "node_modules", pkgName, "package.json")
    +			pkgJsonPath := filepath.Join(npmrc.StoreDir(), pkgName+"@"+version, "node_modules", pkgName, "package.json")
     			if utils.ParseJSONFile(pkgJsonPath, &raw) == nil {
     				return raw.ToNpmPackage(), "", nil
     			}
    @@ -304,8 +304,8 @@ func (npmrc *NpmRC) getPackageInfoByDate(pkgName string, dateVersion string) (pa
     }
     
     func (npmrc *NpmRC) installPackage(pkg npm.Package) (packageJson *npm.PackageJSON, err error) {
    -	installDir := path.Join(npmrc.StoreDir(), pkg.String())
    -	packageJsonPath := path.Join(installDir, "node_modules", pkg.Name, "package.json")
    +	installDir := filepath.Join(npmrc.StoreDir(), pkg.String())
    +	packageJsonPath := filepath.Join(installDir, "node_modules", pkg.Name, "package.json")
     
     	// check if the package has been installed
     	var raw npm.PackageJSONRaw
    @@ -331,12 +331,12 @@ func (npmrc *NpmRC) installPackage(pkg npm.Package) (packageJson *npm.PackageJSO
     			buf := bytes.NewBuffer(nil)
     			buf.WriteString(`{"name":"` + pkg.Name + `","version":"` + pkg.Version + `"`)
     			var denoJson *npm.PackageJSON
    -			if deonJsonPath := path.Join(installDir, "node_modules", pkg.Name, "deno.json"); existsFile(deonJsonPath) {
    +			if deonJsonPath := filepath.Join(installDir, "node_modules", pkg.Name, "deno.json"); existsFile(deonJsonPath) {
     				var raw npm.PackageJSONRaw
     				if utils.ParseJSONFile(deonJsonPath, &raw) == nil {
     					denoJson = raw.ToNpmPackage()
     				}
    -			} else if deonJsoncPath := path.Join(installDir, "node_modules", pkg.Name, "deno.jsonc"); existsFile(deonJsoncPath) {
    +			} else if deonJsoncPath := filepath.Join(installDir, "node_modules", pkg.Name, "deno.jsonc"); existsFile(deonJsoncPath) {
     				data, err := os.ReadFile(deonJsoncPath)
     				if err == nil {
     					var raw npm.PackageJSONRaw
    @@ -383,7 +383,7 @@ func (npmrc *NpmRC) installPackage(pkg npm.Package) (packageJson *npm.PackageJSO
     			return nil, fetchErr
     		}
     		if info.Deprecated != "" {
    -			os.WriteFile(path.Join(installDir, "deprecated.txt"), []byte(info.Deprecated), 0644)
    +			os.WriteFile(filepath.Join(installDir, "deprecated.txt"), []byte(info.Deprecated), 0644)
     		}
     		err = fetchPackageTarball(npmrc.getRegistryByPackageName(pkg.Name), installDir, info.Name, info.Dist.Tarball)
     	}
    @@ -446,13 +446,13 @@ func (npmrc *NpmRC) installDependencies(wd string, pkgJson *npm.PackageJSON, npm
     				return
     			}
     			// link the installed package to the node_modules directory of current build context
    -			linkDir := path.Join(wd, "node_modules", name)
    +			linkDir := filepath.Join(wd, "node_modules", name)
     			_, err = os.Lstat(linkDir)
     			if err != nil && os.IsNotExist(err) {
     				if strings.ContainsRune(name, '/') {
    -					ensureDir(path.Dir(linkDir))
    +					ensureDir(filepath.Dir(linkDir))
     				}
    -				os.Symlink(path.Join(npmrc.StoreDir(), pkg.String(), "node_modules", pkg.Name), linkDir)
    +				os.Symlink(filepath.Join(npmrc.StoreDir(), pkg.String(), "node_modules", pkg.Name), linkDir)
     			}
     			// install dependencies recursively
     			if len(installed.Dependencies) > 0 || (len(installed.PeerDependencies) > 0 && npmMode) {
    @@ -465,8 +465,8 @@ func (npmrc *NpmRC) installDependencies(wd string, pkgJson *npm.PackageJSON, npm
     
     // If the package is deprecated, a depreacted.txt file will be created by the `intallPackage` function
     func (npmrc *NpmRC) isDeprecated(pkgName string, pkgVersion string) (string, error) {
    -	installDir := path.Join(npmrc.StoreDir(), pkgName+"@"+pkgVersion)
    -	data, err := os.ReadFile(path.Join(installDir, "deprecated.txt"))
    +	installDir := filepath.Join(npmrc.StoreDir(), pkgName+"@"+pkgVersion)
    +	data, err := os.ReadFile(filepath.Join(installDir, "deprecated.txt"))
     	if err != nil {
     		if os.IsNotExist(err) {
     			return "", nil
    @@ -532,7 +532,7 @@ func extractPackageTarball(installDir string, pkgName string, tarball io.Reader)
     		return
     	}
     
    -	pkgDir := path.Join(installDir, "node_modules", pkgName)
    +	pkgDir := filepath.Join(installDir, "node_modules", pkgName)
     
     	// extract tarball
     	tr := tar.NewReader(unziped)
    @@ -544,23 +544,26 @@ func extractPackageTarball(installDir string, pkgName string, tarball io.Reader)
     		if err != nil {
     			return err
     		}
    -		// strip tarball root dir
    -		_, name := utils.SplitByFirstByte(h.Name, '/')
    -		filename := path.Join(pkgDir, path.Clean(name))
     		if h.Typeflag != tar.TypeReg {
     			continue
     		}
     		// ignore large files
     		if h.Size > maxAssetFileSize {
     			continue
     		}
    -		extname := path.Ext(filename)
    +		// normalize the filename
    +		_, filename := utils.SplitByFirstByte(utils.NormalizePathname(h.Name)[1:], '/')
    +		if filename == "" {
    +			continue
    +		}
    +		savepath := filepath.Join(pkgDir, filename)
    +		extname := filepath.Ext(savepath)
     		if !(extname != "" && (assetExts[extname[1:]] || slices.Contains(moduleExts, extname) || extname == ".map" || extname == ".css" || extname == ".svelte" || extname == ".vue")) {
     			// ignore unsupported formats
     			continue
     		}
    -		ensureDir(path.Dir(filename))
    -		f, err := os.OpenFile(filename, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0644)
    +		ensureDir(filepath.Dir(savepath))
    +		f, err := os.OpenFile(savepath, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0644)
     		if err != nil {
     			return err
     		}
    @@ -570,7 +573,7 @@ func extractPackageTarball(installDir string, pkgName string, tarball io.Reader)
     			return err
     		}
     		if n != h.Size {
    -			return errors.New("extractPackageTarball: incomplete file: " + name)
    +			return errors.New("extractPackageTarball: incomplete file: " + savepath)
     		}
     	}
     
    
  • server/npmrc_test.go+101 0 added
    @@ -0,0 +1,101 @@
    +package server
    +
    +import (
    +	"archive/tar"
    +	"bytes"
    +	"compress/gzip"
    +	"crypto/rand"
    +	"encoding/hex"
    +	"os"
    +	"path/filepath"
    +	"testing"
    +)
    +
    +func TestExtractPackageTarball(t *testing.T) {
    +	b := make([]byte, 16)
    +	rand.Read(b)
    +	installDir := filepath.Join(os.TempDir(), hex.EncodeToString(b))
    +	defer os.RemoveAll(installDir)
    +
    +	// Create a malicious tarball with path traversal
    +	var buf bytes.Buffer
    +	gw := gzip.NewWriter(&buf)
    +	tw := tar.NewWriter(gw)
    +
    +	// Add a normal file
    +	content := []byte("export const foo = 'bar';")
    +	header := &tar.Header{
    +		Name:     "package/index.js",
    +		Mode:     0644,
    +		Size:     int64(len(content)),
    +		Typeflag: tar.TypeReg,
    +	}
    +	if err := tw.WriteHeader(header); err != nil {
    +		t.Fatal(err)
    +	}
    +	if _, err := tw.Write(content); err != nil {
    +		t.Fatal(err)
    +	}
    +
    +	// Add a large file
    +	largeContent := make([]byte, 1024*1024*51)
    +	rand.Read(largeContent)
    +	header = &tar.Header{
    +		Name:     "package/large.txt",
    +		Mode:     0644,
    +		Size:     int64(len(largeContent)),
    +		Typeflag: tar.TypeReg,
    +	}
    +	if err := tw.WriteHeader(header); err != nil {
    +		t.Fatal(err)
    +	}
    +	if _, err := tw.Write(largeContent); err != nil {
    +		t.Fatal(err)
    +	}
    +
    +	// add a link
    +	header = &tar.Header{
    +		Name:     "package/passwd.txt",
    +		Mode:     0644,
    +		Typeflag: tar.TypeLink,
    +		Linkname: "/etc/passwd",
    +	}
    +	if err := tw.WriteHeader(header); err != nil {
    +		t.Fatal(err)
    +	}
    +
    +	// Add a malicious file with path traversal
    +	bad := []byte("bad")
    +	header = &tar.Header{
    +		Name:     "/../../../bad/bad.txt",
    +		Mode:     0644,
    +		Size:     int64(len(bad)),
    +		Typeflag: tar.TypeReg,
    +	}
    +	if err := tw.WriteHeader(header); err != nil {
    +		t.Fatal(err)
    +	}
    +	if _, err := tw.Write(bad); err != nil {
    +		t.Fatal(err)
    +	}
    +
    +	tw.Close()
    +	gw.Close()
    +
    +	// Call extractPackageTarball with the malicious tarball
    +	if err := extractPackageTarball(installDir, "test-package", bytes.NewReader(buf.Bytes())); err != nil {
    +		t.Errorf("extractPackageTarball returned error: %v", err)
    +	}
    +	if !existsFile(filepath.Join(installDir, "node_modules", "test-package", "index.js")) {
    +		t.Fatal("index.js should be extracted")
    +	}
    +	if existsFile(filepath.Join(installDir, "node_modules", "test-package", "large.txt")) {
    +		t.Fatal("large.txt should not be extracted")
    +	}
    +	if existsFile(filepath.Join(installDir, "node_modules", "test-package", "passwd.txt")) {
    +		t.Fatal("passwd.txt should not be extracted")
    +	}
    +	if !existsFile(filepath.Join(installDir, "node_modules", "test-package", "bad.txt")) {
    +		t.Fatal("bad.txt should be extracted in the root directory")
    +	}
    +}
    
9d77b88c3207

Clean tar save file path (#1236)

https://github.com/esm-dev/esm.shJe XiaNov 17, 2025via ghsa
1 file changed · +1 1
  • server/npmrc.go+1 1 modified
    @@ -546,7 +546,7 @@ func extractPackageTarball(installDir string, pkgName string, tarball io.Reader)
     		}
     		// strip tarball root dir
     		_, name := utils.SplitByFirstByte(h.Name, '/')
    -		filename := path.Join(pkgDir, name)
    +		filename := path.Join(pkgDir, path.Clean(name))
     		if h.Typeflag != tar.TypeReg {
     			continue
     		}
    

Vulnerability mechanics

Generated by null/stub on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.

References

7

News mentions

0

No linked articles in our index yet.