From 036a0b8be4d2d9e5de8f9054e1a4de8cdc71e46b Mon Sep 17 00:00:00 2001 From: Kyle Isom Date: Fri, 13 Mar 2026 00:41:46 -0700 Subject: [PATCH] Fix SEC-07: disable static file directory listing - Add noDirListing handler wrapper that returns 404 for directory requests (paths ending with "/" or empty path) instead of delegating to http.FileServerFS which would render an index page - Wrap the static file server in Register() with noDirListing - Add tests verifying GET /static/ returns 404 and GET /static/style.css still returns 200 Security: directory listings exposed the names of all static assets, leaking framework details. The wrapper blocks directory index responses while preserving normal file serving. Co-Authored-By: Claude Opus 4.6 --- internal/ui/ui.go | 21 ++++++++++++++++++++- internal/ui/ui_test.go | 28 ++++++++++++++++++++++++++++ 2 files changed, 48 insertions(+), 1 deletion(-) diff --git a/internal/ui/ui.go b/internal/ui/ui.go index d6d13b0..a751a58 100644 --- a/internal/ui/ui.go +++ b/internal/ui/ui.go @@ -24,6 +24,7 @@ import ( "log/slog" "net" "net/http" + "strings" "sync" "time" @@ -275,7 +276,10 @@ func (u *UIServer) Register(mux *http.ServeMux) { if err != nil { panic(fmt.Sprintf("ui: static sub-FS: %v", err)) } - uiMux.Handle("GET /static/", http.StripPrefix("/static/", http.FileServerFS(staticSubFS))) + // Security (SEC-07): wrap the file server to suppress directory listings. + // Without this, GET /static/ returns an index of all static assets, + // revealing framework details to an attacker. + uiMux.Handle("GET /static/", http.StripPrefix("/static/", noDirListing(http.FileServerFS(staticSubFS)))) // Redirect root to login. uiMux.HandleFunc("GET /", func(w http.ResponseWriter, r *http.Request) { @@ -530,6 +534,21 @@ func (u *UIServer) renderError(w http.ResponseWriter, r *http.Request, status in // Security: prevents memory exhaustion from oversized POST bodies (gosec G120). const maxFormBytes = 1 << 20 +// noDirListing wraps an http.Handler (typically http.FileServerFS) to return +// 404 for directory requests instead of an auto-generated directory index. +// +// Security (SEC-07): directory listings expose the names of all static assets, +// leaking framework and version information to attackers. +func noDirListing(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if strings.HasSuffix(r.URL.Path, "/") || r.URL.Path == "" { + http.NotFound(w, r) + return + } + next.ServeHTTP(w, r) + }) +} + // securityHeaders returns middleware that adds defensive HTTP headers to every // UI response. // diff --git a/internal/ui/ui_test.go b/internal/ui/ui_test.go index 7b48050..11297db 100644 --- a/internal/ui/ui_test.go +++ b/internal/ui/ui_test.go @@ -355,6 +355,34 @@ func authenticatedGET(t *testing.T, sessionToken string, path string) *http.Requ return req } +// TestStaticDirectoryListingDisabled verifies that GET /static/ returns 404 +// instead of a directory listing (SEC-07). +func TestStaticDirectoryListingDisabled(t *testing.T) { + mux := newTestMux(t) + + req := httptest.NewRequest(http.MethodGet, "/static/", nil) + rr := httptest.NewRecorder() + mux.ServeHTTP(rr, req) + + if rr.Code != http.StatusNotFound { + t.Errorf("GET /static/ status = %d, want %d (directory listing must be disabled)", rr.Code, http.StatusNotFound) + } +} + +// TestStaticFileStillServed verifies that individual static files are still +// served normally after the directory listing fix (SEC-07). +func TestStaticFileStillServed(t *testing.T) { + mux := newTestMux(t) + + req := httptest.NewRequest(http.MethodGet, "/static/style.css", nil) + rr := httptest.NewRecorder() + mux.ServeHTTP(rr, req) + + if rr.Code != http.StatusOK { + t.Errorf("GET /static/style.css status = %d, want %d", rr.Code, http.StatusOK) + } +} + // TestSetPGCredsRejectsHumanAccount verifies that the PUT /accounts/{id}/pgcreds // endpoint returns 400 when the target account is a human (not system) account. func TestSetPGCredsRejectsHumanAccount(t *testing.T) {