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) {