From b4d53102e524c8f4a21d09cbb71ff348fb141a00 Mon Sep 17 00:00:00 2001 From: sneak Date: Thu, 15 Oct 2026 11:34:54 +0000 Subject: [PATCH] server: a directory under /s/ answers 404, not a listing (closes #286) http.FileServer answered /s/ and /s/css/ with an HTML listing of the embedded files, and /s/css with a redirect to that listing. The static handler now looks the name up in the embedded files first and answers 404 when it names a directory; files are served as before. Model: opus-5-5 --- TODO.md | 2 ++ internal/server/routes.go | 34 +++++++++++++++++++++++------ internal/server/routes_test.go | 40 ++++++++++++++++++++++++++++++++++ 3 files changed, 69 insertions(+), 7 deletions(-) diff --git a/TODO.md b/TODO.md index 4fb2e39..e27316f 100644 --- a/TODO.md +++ b/TODO.md @@ -19,6 +19,8 @@ milestone: https://git.eeqj.de/sneak/dnswatcher/milestone/7 # Completed Steps +- 2026-10-15: a request under `/s/` that names a directory, such as `/s/` or + `/s/css`, answers 404 instead of a listing of its files (closes #286). - 2026-10-13: a hostname or a domain whose zone's nameservers all stop answering gets an NS Failure for each, then an NS Recovery (closes #284). - 2026-10-13: an IPv6 address is written in brackets before its port, as in diff --git a/internal/server/routes.go b/internal/server/routes.go index 805ff21..a4969d2 100644 --- a/internal/server/routes.go +++ b/internal/server/routes.go @@ -1,7 +1,9 @@ package server import ( + "io/fs" "net/http" + "strings" "time" sentryhttp "github.com/getsentry/sentry-go/http" @@ -47,13 +49,7 @@ func (s *Server) SetupRoutes() { public.Get("/", s.handlers.HandleDashboard()) // Static assets (embedded CSS/JS) - public.Mount( - "/s", - http.StripPrefix( - "/s", - http.FileServer(http.FS(static.Static)), - ), - ) + public.Mount("/s", http.StripPrefix("/s", staticAssets())) // Health check (standard well-known path) public.Get( @@ -86,3 +82,27 @@ func (s *Server) SetupRoutes() { s.router.Mount("/metrics", metrics) } } + +// staticAssets serves the embedded static assets, with the /s prefix +// already stripped from the path. A request that names a directory +// answers 404: http.FileServer would answer it with a listing of the +// directory, or with a redirect to that listing when the trailing +// slash is missing. +func staticAssets() http.Handler { + files := http.FileServer(http.FS(static.Static)) + + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // fs.Stat refuses a name that is empty or ends in a slash, + // so /s/ and /s/css/ answer 404 here along with /s/css. + name := strings.TrimPrefix(r.URL.Path, "/") + + info, err := fs.Stat(static.Static, name) + if err != nil || info.IsDir() { + http.NotFound(w, r) + + return + } + + files.ServeHTTP(w, r) + }) +} diff --git a/internal/server/routes_test.go b/internal/server/routes_test.go index f8f9c47..5513a9c 100644 --- a/internal/server/routes_test.go +++ b/internal/server/routes_test.go @@ -1,6 +1,7 @@ package server_test import ( + "mime" "net/http" "net/http/httptest" "testing" @@ -288,3 +289,42 @@ func TestMetricsRateLimitComesBeforeAuth(t *testing.T) { t.Errorf("another address: status = %d, want 200", rec.Code) } } + +// TestStaticDirectoriesAnswerNotFound checks that a request naming a +// directory of the static assets, with or without the trailing slash, +// answers 404 rather than a listing of it or a redirect to one, and +// that the stylesheet is still served as CSS. +func TestStaticDirectoriesAnswerNotFound(t *testing.T) { + viper.Reset() + t.Setenv("DNSWATCHER_TARGETS", "example.com") + + srv := routedServer(t) + + for _, path := range []string{"/s", "/s/", "/s/css", "/s/css/"} { + rec := serve(srv, httptest.NewRequestWithContext( + t.Context(), http.MethodGet, path, nil, + )) + + if rec.Code != http.StatusNotFound { + t.Errorf("GET %s: status = %d, want 404", path, rec.Code) + } + } + + rec := serve(srv, httptest.NewRequestWithContext( + t.Context(), http.MethodGet, "/s/css/tailwind.min.css", nil, + )) + + if rec.Code != http.StatusOK { + t.Errorf("GET stylesheet: status = %d, want 200", rec.Code) + } + + contentType := rec.Header().Get("Content-Type") + + mediaType, _, err := mime.ParseMediaType(contentType) + if err != nil || mediaType != "text/css" { + t.Errorf( + "GET stylesheet: Content-Type = %q, want text/css", + contentType, + ) + } +}