VYPR
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.

PackageAffected versionsPatched versions
github.com/esm-dev/esm.shGo
< 0.0.0-20250616164159-0593516c4cfa0.0.0-20250616164159-0593516c4cfa

Affected products

1

Patches

1
0593516c4cfa

Fix SSRF (#1149)

https://github.com/esm-dev/esm.shJe XiaJun 16, 2025via ghsa
8 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

News mentions

0

No linked articles in our index yet.