From 8b18725d263d8612926bf61822200acd41d2862b Mon Sep 17 00:00:00 2001 From: jdtw Date: Fri, 31 Jul 2026 23:40:48 -0700 Subject: [PATCH] Serve only the root path The mux registered "/", which in net/http is a catch-all: every request that did not match /favicon.ico ran the full handler. In a sampled four hours of production logs, 81 of 97 requests were vulnerability scanners probing for PHP and WordPress -- /wp-login.php, /666.php, /backup/ and similar. Each one fetched the King County road alert feed, and each was a candidate to spend a Gemini call once the analyzer cache expired. Go 1.22 added the {$} pattern, which matches the root path and nothing else, so unmatched paths now get a 404 from the mux without reaching the handler. Every asset the page references is an absolute external URL, so nothing was being served from a subpath. The tests assert the absence of work rather than just the status code: a counting feed server confirms the probes fetch the feed zero times, and that the root path still fetches it exactly once. Co-Authored-By: Claude Opus 5 --- internal/server/server.go | 6 ++- internal/server/server_test.go | 74 ++++++++++++++++++++++++++++++++++ 2 files changed, 79 insertions(+), 1 deletion(-) diff --git a/internal/server/server.go b/internal/server/server.go index be18abb..aea15bd 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -107,7 +107,11 @@ func NewHandler(opts *Options) (http.Handler, error) { ServeMux: http.NewServeMux(), } s.Handle("/favicon.ico", http.FileServer(http.FS(fs))) - s.HandleFunc("/", logged(s.flood())) + // {$} matches the root path and nothing else. A plain "/" pattern is a + // catch-all, which meant every scanner probing /wp-login.php and the like + // ran the full handler: fetching the road alert feed, and potentially + // spending a Gemini call. Those requests now get a 404 from the mux. + s.HandleFunc("/{$}", logged(s.flood())) return s, nil } diff --git a/internal/server/server_test.go b/internal/server/server_test.go index b00103b..38590d7 100644 --- a/internal/server/server_test.go +++ b/internal/server/server_test.go @@ -8,6 +8,7 @@ import ( "net" "net/http" "sync" + "sync/atomic" "testing" "time" @@ -169,6 +170,79 @@ func TestOpen(t *testing.T) { } } +// countingHandler records how many requests reached the wrapped handler. +type countingHandler struct { + h http.Handler + n atomic.Int64 +} + +func (c *countingHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + c.n.Add(1) + c.h.ServeHTTP(w, r) +} + +// Only the root path is served. Anything else -- overwhelmingly vulnerability +// scanners probing for PHP and WordPress -- must 404 from the mux without +// reaching the handler, because the handler fetches the road alert feed and +// may spend a Gemini call. +func TestNonRootPathsAreNotServed(t *testing.T) { + feed := &countingHandler{h: newFeedGenerator(t, nil)} + feedURL := startTestServer(t, feed) + h, err := NewHandler(&Options{FeedURL: feedURL, Road: "124th"}) + if err != nil { + t.Fatalf("NewHandler failed: %v", err) + } + server := startTestServer(t, h) + + probes := []string{ + "/wp-login.php", + "/admin/config.php", + "/666.php", + "/wp-content/plugins/core-plugin/include.php", + "/backup/", + "/.well-known/acme-challenge/index.php", + "/some/deep/nested/path", + } + for _, p := range probes { + resp, err := http.Get(server + p) + if err != nil { + t.Fatalf("http.Get(%s) failed: %v", p, err) + } + resp.Body.Close() + if sc := resp.StatusCode; sc != http.StatusNotFound { + t.Errorf("GET %s returned %d, want 404", p, sc) + } + } + + // The point of the 404 is avoiding work, not just the status code. + if n := feed.n.Load(); n != 0 { + t.Errorf("scanner probes fetched the road alert feed %d times, want 0", n) + } +} + +// The root path must still be served, and it does fetch the feed. +func TestRootIsStillServed(t *testing.T) { + feed := &countingHandler{h: newFeedGenerator(t, nil)} + feedURL := startTestServer(t, feed) + h, err := NewHandler(&Options{FeedURL: feedURL, Road: "124th"}) + if err != nil { + t.Fatalf("NewHandler failed: %v", err) + } + server := startTestServer(t, h) + + resp, err := http.Get(server + "/") + if err != nil { + t.Fatalf("http.Get(/) failed: %v", err) + } + defer resp.Body.Close() + if sc := resp.StatusCode; sc != http.StatusOK { + t.Fatalf("GET / returned %d, want 200", sc) + } + if n := feed.n.Load(); n != 1 { + t.Errorf("GET / fetched the road alert feed %d times, want 1", n) + } +} + func TestFavicon(t *testing.T) { h, err := NewHandler(&Options{}) if err != nil {