Summary
Path traversal via package.json main field in JSON-module branch reads arbitrary host files (sibling of CVE-2026-44594)
A malicious npm package whose package.json "main" field ends in .json and contains ../ segments causes the esm.sh build server to read and serve an arbitrary file from the host filesystem. The vulnerable sink is server/build.go:296-315 (the early "json module" branch). PR #1353 added the prefix-check guard at the very similar sink server/build.go:730-734 after CVE-2026-44594 (browser-field path traversal) was disclosed; it did not extend the same guard to the json-module sink in the same function. This sibling reaches the same primitive (path.Join(ctx.wd, "node_modules", pkgName, attacker_controlled) -> os.ReadFile -> embed bytes verbatim in the served module) via pkgJson.Main instead of pkgJson.Browser. v137_4 is affected.
Details
Affected: github.com/esm-dev/esm.sh, tag v137_4 (latest), commit 310a6d8.
server/build.go:296-315 (the early JSON-module branch in buildModule):
// json module
if strings.HasSuffix(entry.main, ".json") {
if analyzeMode {
return
}
var jsonData []byte
jsonPath := path.Join(ctx.wd, "node_modules", ctx.esmPath.PkgName, entry.main)
jsonData, err = os.ReadFile(jsonPath)
if err != nil {
return
}
buffer := &bytes.Buffer{}
buffer.WriteString("export default ")
buffer.Write(jsonData)
err = ctx.storage.Put(ctx.getSavePath(), buffer)
...
meta = &BuildMeta{ExportDefault: true}
return
}
There is no strings.HasPrefix(jsonPath, ctx.wd+string(os.PathSeparator)) guard at this site. Compare to the same function at lines 730-734, where PR #1353 added exactly that guard to close CVE-2026-44594:
filename = path.Join(ctx.wd, "node_modules", ctx.esmPath.PkgName, modulePath)
// check if the filename is within the working directory
if !strings.HasPrefix(filename, ctx.wd+string(os.PathSeparator)) {
return esbuild.OnResolveResult{}, fmt.Errorf("could not resolve module %s", specifier)
}
Reachability via pkgJson.Main. server/build_resolver.go:303-305:
} else if pkgJson.Module != "" && ctx.existsPkgFile(pkgJson.Module) {
entry.update(pkgJson.Module, true)
} else if pkgJson.Main != "" {
entry.update(pkgJson.Main, pkgJson.Type == "module") // <-- no existsPkgFile guard
}
The Module path has an existsPkgFile(pkgJson.Module) guard; the Main path has none. existsPkgFile is also not a traversal check anyway (it is just existsFile(path.Join(...))), but the Main path doesn't even invoke it. entry.main is then taken verbatim into the json-module branch above.
pkgJson.Main is loaded from the raw package.json "main" field at internal/npm/package_json.go:171 with no validation:
p := &PackageJSON{
Name: a.Name,
Version: a.Version,
Type: a.Type,
Main: a.Main.String(), // <-- attacker-controlled, no validation
...
}
path.Join collapses ../ segments, so path.Join(ctx.wd, "node_modules", pkgName, "../../../../server/config.json") resolves to a path outside ctx.wd/node_modules/pkgName. os.ReadFile then returns the bytes, and the surrounding code emits export default <jsonData> in the served module response (and stores it at ctx.getSavePath() for subsequent cache hits).
The output filetype constraint that limited the original CVE-2026-44594 also applies here: the entry must end in .json for this branch, and the embedded content is wrapped in export default ..., so any host file that ends in .json and is JSON-parseable in JS is fully recoverable. The original advisory called out esm.sh's own config.json (npm registry tokens, S3 storage credentials) as the prime target; this variant reaches the same file via a different package.json field. Non-.json host files can also be probed as an existence oracle since os.ReadFile returning an error vs success is observable from the response shape (the json-module branch returns early on read error, so the module response differs from a successful read).
PoC
The PoC at poc.go is a faithful inline reproduction of server/build.go:296-315 plus the post-PR-#1353 patched version of the same logic, in pure Go using only the standard library. It is structured the same way as the existing server/router_test.go style and confirms both halves of the claim (vulnerable site escapes, the existing fix would block it if applied here).
Setup the PoC simulates:
<root>/wd/node_modules/victim-pkg/index.js (legitimate package contents)
<root>/server/config.json (server-private secret outside ctx.wd)
Attacker package.json: { "main": "../../../server/config.json" }.
Run:
$ go run poc.go
=== Path traversal via package.json main (json-module branch) ===
wd: /tmp/esmsh_poc_.../wd
pkgDir: /tmp/esmsh_poc_.../wd/node_modules/victim-pkg
secret: /tmp/esmsh_poc_.../server/config.json
main: "../../../server/config.json"
[PASS] BASELINE: legitimate main resolves on vulnerable path
[PASS] VULN: vulnerableJSONBranch reads file OUTSIDE ctx.wd
[PASS] VULN: server-private secret bytes returned to caller
Returned 58 bytes; first 80: "{\"npmToken\":\"npm_VICTIM_TOKEN\",\"s3SecretKey\":\"AWS_VICTIM\"}"
[PASS] CONTROL: PR #1353's prefix-check rejects the traversal when applied here
[PASS] CONTROL: PR #1353's prefix-check still accepts legitimate ./data.json
=== 5 passed, 0 failed ===
STATUS: VERIFIED
The "CONTROL" assertion is the load-bearing one: PR #1353's exact prefix-check logic, when applied at the same site, rejects the traversal. So the fix exists in the repo, it was just not extended to the json-module branch.
End-to-end reproduction against a running esm.sh instance:
-
Publish an npm package to a registry esm.sh can reach (the public npm or a private registry configured in config.json):
package.json:
{ "name": "<attacker-pkg>", "version": "1.0.0", "main": "../../../server/config.json" }
The package may have an empty stub index.js; it is not consulted because entry.main is taken straight from pkgJson.Main.
-
curl https://<esm.sh-host>/<attacker-pkg>@1.0.0
-
The response is export default <contents of server/config.json> (or any other .json file the server process can read). For a self-hosted esm.sh that's the npm registry tokens and S3 secret access key from config.json; on any host it's also the file-existence oracle for arbitrary host paths.
The path through router.go to buildModule is the same as the path used by CVE-2026-44594, so the same exploitation prerequisites apply (any package that the server is willing to build is sufficient).
Impact
High, matching the published GHSA-rg65-45m7-hq57 / CVE-2026-44594 advisory (which is high for the browser-field variant on the public esm.sh, and described impact is "read sensitive files from the server, including the esm.sh config.json which may contain npm registry authentication tokens and S3 storage credentials"). This sibling reaches the same config.json plus any other .json file the server uid can read, with no precondition beyond publishing an npm package - i.e. exactly the same threat model the original advisory described.
Suggested fix (for the maintainer's reference): port the line-730-style guard back to the json-module branch:
jsonPath := path.Join(ctx.wd, "node_modules", ctx.esmPath.PkgName, entry.main)
if !strings.HasPrefix(jsonPath, ctx.wd+string(os.PathSeparator)) {
err = fmt.Errorf("could not resolve module %s", entry.main)
return
}
Same site also benefits from an early entry.main guard in build_resolver.go:303-305 so neither pkgJson.Main nor pkgJson.Module propagates ../ further into the resolver. The same guard pattern, applied to transformDTS(dts) at server/dts_transform.go:51 (which also does path.Join(ctx.wd, "node_modules", pkgName, dts) with attacker-controlled dts from pkgJson.Types), would close another sibling site of the same class.
Summary
Path traversal via package.json
mainfield in JSON-module branch reads arbitrary host files (sibling of CVE-2026-44594)A malicious npm package whose
package.json"main" field ends in.jsonand contains../segments causes the esm.sh build server to read and serve an arbitrary file from the host filesystem. The vulnerable sink isserver/build.go:296-315(the early "json module" branch). PR #1353 added the prefix-check guard at the very similar sinkserver/build.go:730-734after CVE-2026-44594 (browser-field path traversal) was disclosed; it did not extend the same guard to the json-module sink in the same function. This sibling reaches the same primitive (path.Join(ctx.wd, "node_modules", pkgName, attacker_controlled)->os.ReadFile-> embed bytes verbatim in the served module) viapkgJson.Maininstead ofpkgJson.Browser. v137_4 is affected.Details
Affected: github.com/esm-dev/esm.sh, tag v137_4 (latest), commit 310a6d8.
server/build.go:296-315(the early JSON-module branch inbuildModule):There is no
strings.HasPrefix(jsonPath, ctx.wd+string(os.PathSeparator))guard at this site. Compare to the same function at lines 730-734, where PR #1353 added exactly that guard to close CVE-2026-44594:Reachability via
pkgJson.Main.server/build_resolver.go:303-305:The Module path has an
existsPkgFile(pkgJson.Module)guard; the Main path has none.existsPkgFileis also not a traversal check anyway (it is justexistsFile(path.Join(...))), but the Main path doesn't even invoke it.entry.mainis then taken verbatim into the json-module branch above.pkgJson.Mainis loaded from the rawpackage.json"main" field atinternal/npm/package_json.go:171with no validation:path.Joincollapses../segments, sopath.Join(ctx.wd, "node_modules", pkgName, "../../../../server/config.json")resolves to a path outsidectx.wd/node_modules/pkgName.os.ReadFilethen returns the bytes, and the surrounding code emitsexport default <jsonData>in the served module response (and stores it atctx.getSavePath()for subsequent cache hits).The output filetype constraint that limited the original CVE-2026-44594 also applies here: the entry must end in
.jsonfor this branch, and the embedded content is wrapped inexport default ..., so any host file that ends in.jsonand is JSON-parseable in JS is fully recoverable. The original advisory called out esm.sh's ownconfig.json(npm registry tokens, S3 storage credentials) as the prime target; this variant reaches the same file via a different package.json field. Non-.jsonhost files can also be probed as an existence oracle sinceos.ReadFilereturning an error vs success is observable from the response shape (the json-module branch returns early on read error, so the module response differs from a successful read).PoC
The PoC at
poc.gois a faithful inline reproduction ofserver/build.go:296-315plus the post-PR-#1353 patched version of the same logic, in pure Go using only the standard library. It is structured the same way as the existingserver/router_test.gostyle and confirms both halves of the claim (vulnerable site escapes, the existing fix would block it if applied here).Setup the PoC simulates:
Attacker package.json:
{ "main": "../../../server/config.json" }.Run:
The "CONTROL" assertion is the load-bearing one: PR #1353's exact prefix-check logic, when applied at the same site, rejects the traversal. So the fix exists in the repo, it was just not extended to the json-module branch.
End-to-end reproduction against a running esm.sh instance:
Publish an npm package to a registry esm.sh can reach (the public npm or a private registry configured in
config.json):The package may have an empty stub
index.js; it is not consulted becauseentry.mainis taken straight frompkgJson.Main.curl https://<esm.sh-host>/<attacker-pkg>@1.0.0The response is
export default <contents of server/config.json>(or any other.jsonfile the server process can read). For a self-hosted esm.sh that's the npm registry tokens and S3 secret access key fromconfig.json; on any host it's also the file-existence oracle for arbitrary host paths.The path through router.go to
buildModuleis the same as the path used by CVE-2026-44594, so the same exploitation prerequisites apply (any package that the server is willing to build is sufficient).Impact
High, matching the published GHSA-rg65-45m7-hq57 / CVE-2026-44594 advisory (which is
highfor the browser-field variant on the public esm.sh, and described impact is "read sensitive files from the server, including the esm.shconfig.jsonwhich may contain npm registry authentication tokens and S3 storage credentials"). This sibling reaches the sameconfig.jsonplus any other.jsonfile the server uid can read, with no precondition beyond publishing an npm package - i.e. exactly the same threat model the original advisory described.Suggested fix (for the maintainer's reference): port the line-730-style guard back to the json-module branch:
Same site also benefits from an early
entry.mainguard inbuild_resolver.go:303-305so neitherpkgJson.MainnorpkgJson.Modulepropagates../further into the resolver. The same guard pattern, applied totransformDTS(dts)atserver/dts_transform.go:51(which also doespath.Join(ctx.wd, "node_modules", pkgName, dts)with attacker-controlleddtsfrompkgJson.Types), would close another sibling site of the same class.