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

esm.sh's /http(s) fetch route blocked localhost by hostname string only, so 127.0.0.1.nip.io bypassed the filter and let external users fetch internal services.

- Payout: Research
- Severity: high
- Program: esm.sh
- Found by: poppo25
- Disclosed: 2026-02-25
- CVE: CVE-2026-27730
- Write-up quality: 7/10
- Original: https://github.com/advisories/GHSA-p2v6-84h2-5x4r
- Library page: http://bugbountylibrary.com/r/ghsa%3AGHSA-p2v6-84h2-5x4r
- Tags: area:web domain:web impact:internal-access kind:research platform:ghsa sev:high tech:go technique:recon technique:source-review vuln:access-control vuln:ssrf

**Target:** esm.sh /http(s) module fetch route (Go HTTP service)
**Root cause:** The localhost/private-network block in server/router.go and isLocalhost() compares hostname strings (localhost, 127.0.0.1, 192.168.*) and the fetch client allowlists url.Host strings, never validating the resolved destination IP after DNS resolution.
**Steps:**
1. Run esm.sh: `docker run -d --name esmsh-5558 -p 5558:80 ghcr.io/esm-dev/esm.sh:latest`
2. Start an internal-only Flask service on port 5555 in the same network namespace: `docker run -d --name internal-5555 --network container:esmsh-5558 ... python app.py` serving `/secret.js`
3. Expose esm.sh externally via `cloudflared tunnel --url http://127.0.0.1:5558`
4. Request `curl -i "https://ESM.SH_SERVER/http://127.0.0.1.nip.io:5555/secret.js"` — direct `127.0.0.1` is rejected, the nip.io alias resolves to 127.0.0.1 and passes the string check
5. Observe the internal `secret;` JavaScript body returned through esm.sh
**How found:** Reporter read the /http(s) route handler and noticed the block was purely hostname-string based (isLocalhost + valid.IsDomain + allowedHosts map keyed on url.Host), with no post-DNS IP validation — then hypothesized a DNS alias domain resolving to 127.0.0.1 would slip through and confirmed it in a Docker repro.
**Exploitation:** Attacker sends a request to the public /http:// or /https:// route with a hostname that resolves to loopback/private IP (e.g. 127.0.0.1.nip.io). The server's string-based checks pass, the fetch client resolves DNS and connects to the internal service, and the response is proxied back. Extension allowlist (.js, .ts, .md, .css, etc.) limits arbitrary file fetch but still allows internal service probing and retrieval of matching resources; Tomcat path-parameter tricks (`/asdf/;asdf=a.js`) may bypass the extension check.
**Escalation:** Extension allowlist partially mitigated by Apache Tomcat path-parameter interpretation, potentially widening to arbitrary internal paths.
**Insight:** When a proxy/fetcher blocks internal targets, check whether the check runs on the hostname string or on the resolved IP; any allowlist keyed on url.Host is bypassable with wildcard DNS services like nip.io, sslip.io or xip.io that map names to arbitrary IPs.

## Proof of concept (copied exactly from the original report)

**curl SSRF via nip.io DNS alias**

```
curl -i "https://ESM.SH_SERVER/http://127.0.0.1.nip.io:5555/secret.js"
```

**Internal Flask secret.js test server**

```
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)
```

**Docker run of internal server in shared namespace**

```
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"
```

