High severityNVD Advisory· Published Feb 25, 2026· Updated Feb 25, 2026
esm.sh has SSRF localhost/private-network bypass in `/http(s)` module route
CVE-2026-27730
Description
esm.sh is a no-build content delivery network (CDN) for web development. Versions up to and including 137 have an SSRF vulnerability (CWE-918) 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. This allows an external requester to make the esm.sh server fetch internal localhost services. As of time of publication, no known patched versions exist.
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
6- github.com/advisories/GHSA-p2v6-84h2-5x4rghsaADVISORY
- nvd.nist.gov/vuln/detail/CVE-2026-27730ghsaADVISORY
- github.com/esm-dev/esm.sh/commit/0593516c4cfab49ad3b4900416a8432ff2e23eb0ghsaWEB
- github.com/esm-dev/esm.sh/pull/1149ghsaWEB
- github.com/esm-dev/esm.sh/releases/tag/v137ghsaWEB
- github.com/esm-dev/esm.sh/security/advisories/GHSA-p2v6-84h2-5x4rghsax_refsource_CONFIRMWEB
News mentions
0No linked articles in our index yet.