Feb 26, 2026·6 min read·3 visits
Critical SSRF in esm.sh allows internal network access via DNS aliasing. The application validated URL strings instead of resolved IPs, enabling attackers to bypass 'localhost' blocks using domains like '127.0.0.1.nip.io'.
A critical Server-Side Request Forgery (SSRF) vulnerability in esm.sh allowed attackers to bypass string-based hostname validation using DNS aliases. By masking internal IP addresses behind innocent-looking domain names, attackers could trick the CDN into scanning local networks or retrieving cloud metadata. While a patch attempted to pin hosts during redirects, the fundamental flaw of validating hostnames before DNS resolution remains a classic example of 'checking the ID card but ignoring the face'.
In the modern web development ecosystem, 'no-build' CDNs like esm.sh are the new hotness. They allow developers to import npm packages directly into the browser as ES modules, handling all the transpilation and bundling on the fly. To achieve this magic, the service needs to fetch external resources. It acts as a highly specialized proxy, taking a URL, processing the content, and serving it back.
But here is the golden rule of web security: If you let a user tell your server where to go, they will eventually send it to hell.
The vulnerability lies in the /http(s) fetch route. This feature is designed to proxy and bundle external modules. From a hacker's perspective, this is not a 'feature'; it is an open invitation. It is a portal that accepts a URL and makes an HTTP request from the context of the server. If we can manipulate that request, we are no longer just visiting the website; we are inside their network.
The developers of esm.sh knew that SSRF was a risk. They implemented a safeguard function, likely something akin to isLocalhost, to forbid requests to loopback addresses. Their logic was simple: check the input URL.
If the hostname is "localhost", block it. If it is "127.0.0.1", block it. If it starts with "192.168.", block it.
This is the String Comparison Fallacy. It is like a bouncer at a club who has a list of banned names. If 'John Doe' is banned, but John shows up wearing a fake mustache and calls himself 'Juan Dough', the bouncer lets him in. The bouncer checks the name (the string), not the person (the IP address).
In the world of TCP/IP, computers do not care about strings; they care about IP addresses. Validation that happens before DNS resolution is effectively useless against a motivated attacker. We don't need to use the string "localhost"; we just need a string that becomes localhost when the server asks the DNS resolver.
Let's look at the fix introduced in commit 0593516c4cfab49ad3b4900416a8432ff2e23eb0. The developers realized that redirects were a major issue. Previously, you could point the validator to safe.com, which would then return a 302 Redirect to localhost. The initial check passes, and the HTTP client blindly follows the redirect into the danger zone.
To fix this, they introduced a map called allowedHosts and a CheckRedirect hook:
// Simplified view of the patch
client := &http.Client{
CheckRedirect: func(req *http.Request, via []*http.Request) error {
// Only allow if the host was explicitly allowed during the initial check
if _, ok := allowedHosts[req.URL.Host]; !ok {
return fmt.Errorf("redirect host %s not allowed", req.URL.Host)
}
return nil
},
}This code (paraphrased for clarity) effectively pins the request to the original hostname. If you start at example.com, you must stay at example.com.
The Problem? This fixes redirect-based SSRF, but it arguably misses the forest for the trees. If the initial allowedHost is evil-alias.com, and evil-alias.com resolves to 127.0.0.1, the map entry is created for evil-alias.com. The redirect check is never triggered because there is no redirect—just a direct connection to a local IP disguised as a public domain.
Exploiting this requires zero coding and about 30 seconds of setup. We utilize a 'magic' DNS service like nip.io or sslip.io. These services automatically resolve subdomains to the IP address contained within the string.
The Attack Chain:
127.0.0.1.nip.io. To the esm.sh validator, this string does not equal "localhost" or "127.0.0.1". It looks like a valid external domain.GET https://esm.sh/http://127.0.0.1.nip.io:80/adminWhat happens inside the server?
if "127.0.0.1.nip.io" in blacklist. False. Request allowed.net/http client asks DNS: "Who is 127.0.0.1.nip.io?"127.0.0.1". The client connects to itself.> [!NOTE]
> Cloud environments are even juicier. Using 169.254.169.254.nip.io allows an attacker to steal AWS IAM credentials or instance metadata.
The impact of a blind SSRF like this ranges from "annoying" to "business-ending," depending on where the esm.sh instance is hosted.
If hosted in a Kubernetes cluster, an attacker can hit the Kubelet API, potentially dumping pod configurations or secrets. If hosted on AWS/GCP/Azure, the Instance Metadata Service (IMDS) is the primary target. While modern IMDS (IMDSv2) requires a token header, many legacy setups or misconfigured proxies (which might forward headers inadvertently) are still vulnerable to IMDSv1.
Furthermore, this turns the CDN into a port scanner. An attacker can fuzz ports on the local network (192.168.1.1.nip.io:22, :23, :25) and analyze the response times or error messages to map out the internal infrastructure. The server becomes a pivot point for further attacks.
The patch in version 137 adds friction by preventing redirects, which is good practice. However, the only true fix for SSRF is DNS Resolution at the Application Layer.
How to actually fix this:
Host header manually to the original hostname (to support virtual hosting).Until the validation logic moves from String-land to IP-land, bypasses will likely continue to surface.
CVSS:3.0/AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:N/A:N| Product | Affected Versions | Fixed Version |
|---|---|---|
esm.sh esm-dev | <= 137 | 137 |
| Attribute | Detail |
|---|---|
| CWE ID | CWE-918 |
| Attack Vector | Network |
| CVSS v3.0 | 8.6 (High) |
| Attack Complexity | Low |
| Privileges Required | None |
| Exploit Maturity | Proof of Concept |
Server-Side Request Forgery (SSRF)