answer 404 for a static path the root refuses

Found by /invariants: a symlink under static/ pointing outside the site root
answered 500. The guard held — os.Root refused it and no bytes escaped — but the
response confirmed the path was there, where every other miss answers 404. Same
reasoning as a hidden bundle answering 404 rather than 403 (ADR-0024).

serveStatic now stats through the rooted FS first, so a directory, a missing
file, and a refused name are one answer. That also folds the old noListing and
staticFS into one function, since "cannot serve this" was already their shared
job.

The test uses a real temp directory rather than a MapFS, because the guard under
test belongs to os.Root; verified it fails with 500 against the previous code
before keeping it.

Splitting web_test.go at the seam the package already had — resolve_test.go for
what a path means, web_test.go for what happens once it resolves — because it
crossed FILE_LOC_WARN. Same response as content.go at entry 9.
This commit is contained in:
2026-08-01 02:23:35 +06:00
parent 7117d70b45
commit d80885a412
4 changed files with 148 additions and 96 deletions
+13 -14
View File
@@ -21,29 +21,28 @@ func Handler(site *content.Site, r *render.Renderer, siteFS fs.FS) http.Handler
serve(w, req, site, r)
})
if siteFS != nil {
mux.Handle("GET /static/", http.StripPrefix("/static/", noListing(staticFS(siteFS))))
if sub, err := fs.Sub(siteFS, "static"); err == nil {
mux.Handle("GET /static/", http.StripPrefix("/static/", serveStatic(sub)))
}
}
return mux
}
// staticFS serves the site root's static/ directory verbatim. It keeps the os.Root guarantee, because
// serveStatic serves the site root's static/ directory verbatim. It keeps the os.Root guarantee, because
// the fs.FS it is given is the one rooted there (ADR-0031).
func staticFS(siteFS fs.FS) http.Handler {
sub, err := fs.Sub(siteFS, "static")
if err != nil {
return http.NotFoundHandler()
}
return http.FileServerFS(sub)
}
// noListing refuses directory paths, so static/ never answers with an index of its own contents.
func noListing(h http.Handler) http.Handler {
//
// Anything it cannot serve answers 404: a directory, a missing file, or a name the root refuses because it
// resolves outside. A listing would expose the tree, and an error page for a refused symlink would confirm
// the path is there — the same reason a hidden bundle answers 404 rather than 403 (ADR-0024).
func serveStatic(sub fs.FS) http.Handler {
files := http.FileServerFS(sub)
return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
if req.URL.Path == "" || strings.HasSuffix(req.URL.Path, "/") {
info, err := fs.Stat(sub, strings.TrimPrefix(req.URL.Path, "/"))
if err != nil || info.IsDir() {
http.NotFound(w, req)
return
}
h.ServeHTTP(w, req)
files.ServeHTTP(w, req)
})
}