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 <noreply@anthropic.com>
This commit is contained in:
2026-03-13 00:41:46 -07:00
parent 586d4e3355
commit 036a0b8be4
2 changed files with 48 additions and 1 deletions

View File

@@ -24,6 +24,7 @@ import (
"log/slog" "log/slog"
"net" "net"
"net/http" "net/http"
"strings"
"sync" "sync"
"time" "time"
@@ -275,7 +276,10 @@ func (u *UIServer) Register(mux *http.ServeMux) {
if err != nil { if err != nil {
panic(fmt.Sprintf("ui: static sub-FS: %v", err)) 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. // Redirect root to login.
uiMux.HandleFunc("GET /", func(w http.ResponseWriter, r *http.Request) { 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). // Security: prevents memory exhaustion from oversized POST bodies (gosec G120).
const maxFormBytes = 1 << 20 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 // securityHeaders returns middleware that adds defensive HTTP headers to every
// UI response. // UI response.
// //

View File

@@ -355,6 +355,34 @@ func authenticatedGET(t *testing.T, sessionToken string, path string) *http.Requ
return req 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 // TestSetPGCredsRejectsHumanAccount verifies that the PUT /accounts/{id}/pgcreds
// endpoint returns 400 when the target account is a human (not system) account. // endpoint returns 400 when the target account is a human (not system) account.
func TestSetPGCredsRejectsHumanAccount(t *testing.T) { func TestSetPGCredsRejectsHumanAccount(t *testing.T) {