High severityNVD Advisory· Published Feb 25, 2026· Updated Feb 27, 2026
esm.sh is vulnerable to full-response SSRF
CVE-2025-50180
Description
esm.sh is a no-build content delivery network (CDN) for web development. In version 136, esm.sh is vulnerable to a full-response SSRF, allowing an attacker to retrieve information from internal websites through the vulnerability. Version 137 fixes the vulnerability.
Affected packages
Versions sourced from the GitHub Security Advisory.
| Package | Affected versions | Patched versions |
|---|---|---|
github.com/esm-dev/esm.shGo | < 0.0.0-20250616164159-0593516c4cfa | 0.0.0-20250616164159-0593516c4cfa |
Affected products
1Patches
18 files changed · +59 −10
internal/fetch/fetch.go+14 −2 modified@@ -17,18 +17,25 @@ var clientPool = sync.Pool{ // FetchClient is a custom HTTP client. type FetchClient struct { *http.Client - userAgent string + userAgent string + allowedHosts map[string]struct{} } // NewClient creates a new FetchClient. -func NewClient(userAgent string, timeout int, reserveRedirect bool) (client *FetchClient, recycle func()) { +func NewClient(userAgent string, timeout int, reserveRedirect bool, allowedHosts map[string]struct{}) (client *FetchClient, recycle func()) { client = clientPool.Get().(*FetchClient) client.userAgent = userAgent client.Timeout = time.Duration(timeout) * time.Second client.CheckRedirect = func(req *http.Request, via []*http.Request) error { if reserveRedirect && len(via) > 0 { return http.ErrUseLastResponse } + // To avoid SSRF attacks, we check if the request URL's host is in the allowed hosts list. + if allowedHosts != nil { + if _, ok := allowedHosts[req.URL.Host]; !ok { + return http.ErrUseLastResponse + } + } if len(via) >= 3 { return errors.New("stopped after 3 redirects") } @@ -39,6 +46,11 @@ func NewClient(userAgent string, timeout int, reserveRedirect bool) (client *Fet // Do sends an HTTP request and returns the response. 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)
server/git.go+1 −1 modified@@ -63,7 +63,7 @@ func ghInstall(wd, name, tag string) (err error) { if err != nil { return } - client, recycle := fetch.NewClient("esmd/"+VERSION, 30, false) + client, recycle := fetch.NewClient("esmd/"+VERSION, 30, false, nil) defer recycle() res, err := client.Fetch(u, nil) if err != nil {
server/legacy_router.go+1 −1 modified@@ -256,7 +256,7 @@ func legacyESM(ctx *rex.Context, buildStorage storage.Storage, buildVersionPrefi return rex.Status(http.StatusBadRequest, "Invalid url") } - client, recycle := fetch.NewClient(ctx.UserAgent(), 60, true) + client, recycle := fetch.NewClient(ctx.UserAgent(), 60, true, nil) defer recycle() res, err := client.Fetch(url, nil)
server/npmrc.go+2 −2 modified@@ -162,7 +162,7 @@ func (npmrc *NpmRC) getPackageInfo(pkgName string, version string) (packageJson header.Set("Authorization", "Basic "+base64.StdEncoding.EncodeToString([]byte(reg.User+":"+reg.Password))) } - fetchClient, recycle := fetch.NewClient("esmd/"+VERSION, 15, false) + fetchClient, recycle := fetch.NewClient("esmd/"+VERSION, 15, false, nil) defer recycle() retryTimes := 0 @@ -451,7 +451,7 @@ func fetchPackageTarball(reg *NpmRegistry, installDir string, pkgName string, ta header.Set("Authorization", "Basic "+base64.StdEncoding.EncodeToString([]byte(reg.User+":"+reg.Password))) } - fetchClient, recycle := fetch.NewClient("esmd/"+VERSION, 30, false) + fetchClient, recycle := fetch.NewClient("esmd/"+VERSION, 30, false, nil) defer recycle() retryTimes := 0
server/router.go+11 −3 modified@@ -217,7 +217,7 @@ func esmRouter(db Database, buildStorage storage.Storage, logger *log.Logger) re indexHTML, err := withCache("index.html", time.Duration(cacheTtl)*time.Second, func() (indexHTML []byte, _ string, err error) { readme, err := os.ReadFile("README.md") if err != nil { - fetchClient, recycle := fetch.NewClient(ctx.UserAgent(), 15, false) + fetchClient, recycle := fetch.NewClient(ctx.UserAgent(), 15, false, nil) defer recycle() readmeUrl, _ := url.Parse("https://raw.githubusercontent.com/esm-dev/esm.sh/refs/heads/main/README.md") var res *http.Response @@ -537,7 +537,9 @@ func esmRouter(db Database, buildStorage storage.Storage, logger *log.Logger) re if v != "" && (!npm.Versioning.Match(v) || len(v) > 32) { return rex.Status(400, "Invalid Version Param") } - fetchClient, recycle := fetch.NewClient(ctx.UserAgent(), 15, false) + allowedHosts := map[string]struct{}{} + allowedHosts[modUrl.Host] = struct{}{} + fetchClient, recycle := fetch.NewClient(ctx.UserAgent(), 15, false, allowedHosts) defer recycle() if strings.HasSuffix(modUrl.Path, "/uno.css") { ctxParam := query.Get("ctx") @@ -725,7 +727,13 @@ func esmRouter(db Database, buildStorage storage.Storage, logger *log.Logger) re } defer res.Body.Close() if res.StatusCode != 200 { - return rex.Status(500, "Failed to fetch import map") + if res.StatusCode == 404 { + return rex.Status(404, "Import map not found") + } + if res.StatusCode == 301 || res.StatusCode == 302 || res.StatusCode == 307 || res.StatusCode == 308 { + return rex.Status(400, "Failed to fetch import map: redirects are not allowed") + } + return rex.Status(500, "Failed to fetch import map: "+res.Status) } tokenizer := html.NewTokenizer(io.LimitReader(res.Body, 5*MB)) for {
server/server.go+1 −1 modified@@ -162,7 +162,7 @@ func customLandingPage(options *LandingPageOptions) rex.Handle { if err != nil { return rex.Err(http.StatusBadRequest, "Invalid url") } - fetchClient, recycle := fetch.NewClient(ctx.UserAgent(), 15, false) + fetchClient, recycle := fetch.NewClient(ctx.UserAgent(), 15, false, nil) defer recycle() res, err := fetchClient.Fetch(url, nil) if err != nil {
server/transform.go+6 −0 modified@@ -217,6 +217,12 @@ func bundleHttpModule(npmrc *NpmRC, entry string, importMap importmap.ImportMap, } defer res.Body.Close() if res.StatusCode != 200 { + if res.StatusCode == 404 { + return esbuild.OnLoadResult{}, errors.New("module not found: " + args.Path) + } + if res.StatusCode == 301 || res.StatusCode == 302 || res.StatusCode == 307 || res.StatusCode == 308 { + return esbuild.OnLoadResult{}, errors.New("failed to fetch module " + args.Path + ": redirect not allowed") + } return esbuild.OnLoadResult{}, errors.New("failed to fetch module " + args.Path + ": " + res.Status) } data, err := io.ReadAll(io.LimitReader(res.Body, 5*MB))
test/transform/transform.test.ts+23 −0 modified@@ -15,6 +15,12 @@ Deno.test("transform", async (t) => { if (pathname.endsWith("/")) { pathname += "index.html"; } + if (pathname == "/readme.md") { + return new Response("# esm.sh"); + } + if (pathname == "/SSRF/readme.md") { + return Response.redirect("http://192.168.1.42/readme.md", 302); + } try { const file = join(demoRootDir, pathname); const f = await Deno.open(file); @@ -183,6 +189,23 @@ Deno.test("transform", async (t) => { }); await t.step("transform module: markdown", async () => { + { + const res = await fetch(`http://localhost:8080/http://localhost:8083/readme.md`); + assertEquals(res.status, 200); + assertEquals(res.headers.get("Content-Type"), "application/javascript; charset=utf-8"); + assertEquals(res.headers.get("Cache-Control"), "public, max-age=31536000, immutable"); + const js = await res.text(); + assertStringIncludes(js, `h1 id="esmsh">esm.sh</h1>`); + } + { + const res = await fetch(`http://localhost:8080/http://localhost:8083/SSRF/readme.md`); + assertEquals(res.status, 500); + const error = await res.text(); + assertStringIncludes( + error, + "Failed to build module: failed to fetch module http://localhost:8083/SSRF/readme.md: redirect not allowed", + ); + } { const res = await fetch(`http://localhost:8080/https://raw.githubusercontent.com/esm-dev/esm.sh/refs/heads/main/README.md`); assertEquals(res.status, 200);
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
8- github.com/advisories/GHSA-3c9r-837r-qqm4ghsaADVISORY
- nvd.nist.gov/vuln/detail/CVE-2025-50180ghsaADVISORY
- github.com/esm-dev/esm.sh/blob/f80ff8c8d58749e77fa964abde468fc61f8bd89e/internal/fetch/fetch.goghsax_refsource_MISCWEB
- github.com/esm-dev/esm.sh/blob/f80ff8c8d58749e77fa964abde468fc61f8bd89e/server/router.goghsax_refsource_MISCWEB
- github.com/esm-dev/esm.sh/commit/0593516c4cfab49ad3b4900416a8432ff2e23eb0ghsax_refsource_MISCWEB
- github.com/esm-dev/esm.sh/pull/1149ghsax_refsource_MISCWEB
- github.com/esm-dev/esm.sh/releases/tag/v137ghsax_refsource_MISCWEB
- github.com/esm-dev/esm.sh/security/advisories/GHSA-3c9r-837r-qqm4ghsax_refsource_CONFIRMWEB
News mentions
0No linked articles in our index yet.