From 15994474f6c061a6a1db525f19b72ec0ba0e42a0 Mon Sep 17 00:00:00 2001 From: henrygd Date: Sat, 26 Sep 2026 10:16:21 -0400 Subject: [PATCH] hub: return 404 status for unknown frontend routes (#2414) --- internal/hub/server.go | 26 ++++++++++++++ internal/hub/server_production.go | 9 ++++- internal/hub/server_test.go | 59 +++++++++++++++++++++++++++++++ 3 files changed, 93 insertions(+), 1 deletion(-) create mode 100644 internal/hub/server_test.go diff --git a/internal/hub/server.go b/internal/hub/server.go index 071139897..3e4f64d02 100644 --- a/internal/hub/server.go +++ b/internal/hub/server.go @@ -28,6 +28,32 @@ func modifyIndexHTML(hub *Hub, html []byte) string { return strings.Replace(htmlContent, "\"{info}\"", string(content), 1) } +// isAppRoute reports whether urlPath matches a frontend route, so unknown paths +// can be served with a 404 status. The base path prefix is optional because +// reverse proxies may or may not strip it before forwarding. +// +// Keep in sync with routes in internal/site/src/components/router.tsx. +func isAppRoute(urlPath, basePath string) bool { + urlPath = strings.ToLower(urlPath) + if base := strings.TrimSuffix(strings.ToLower(basePath), "/"); base != "" { + if rest, ok := strings.CutPrefix(urlPath, base); ok && (rest == "" || rest[0] == '/') { + urlPath = rest + } + } + urlPath = strings.TrimSuffix(urlPath, "/") + switch urlPath { + case "", "/containers", "/smart", "/monitors", "/settings", "/forgot-password", "/request-otp": + return true + } + // routes with a single required (/system/:id) or optional (/settings/:name?) param + for _, prefix := range [...]string{"/system/", "/settings/"} { + if param, ok := strings.CutPrefix(urlPath, prefix); ok { + return param != "" && !strings.Contains(param, "/") + } + } + return false +} + func getPublicAppInfo(hub *Hub) PublicAppInfo { parsedURL, _ := url.Parse(hub.appURL) info := PublicAppInfo{ diff --git a/internal/hub/server_production.go b/internal/hub/server_production.go index 4a444a657..0cc7a3111 100644 --- a/internal/hub/server_production.go +++ b/internal/hub/server_production.go @@ -18,6 +18,7 @@ import ( func (h *Hub) startServer(se *core.ServeEvent) error { indexFile, _ := fs.ReadFile(site.DistDirFS, "index.html") html := modifyIndexHTML(h, indexFile) + basePath := getPublicAppInfo(h).BASE_PATH // set up static asset serving staticPaths := [2]string{"/static/", "/assets/"} serveStatic := apis.Static(site.DistDirFS, false) @@ -36,7 +37,13 @@ func (h *Hub) startServer(se *core.ServeEvent) error { e.Response.Header().Del("X-Frame-Options") e.Response.Header().Set("Content-Security-Policy", csp) } - return e.HTML(http.StatusOK, html) + // still serve the app for unknown paths (it renders a 404 page), + // but with a 404 status so scanners and fail2ban see the miss + status := http.StatusOK + if !isAppRoute(e.Request.URL.Path, basePath) { + status = http.StatusNotFound + } + return e.HTML(status, html) }) return nil } diff --git a/internal/hub/server_test.go b/internal/hub/server_test.go new file mode 100644 index 000000000..4dcbde90a --- /dev/null +++ b/internal/hub/server_test.go @@ -0,0 +1,59 @@ +//go:build testing + +package hub + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestIsAppRoute(t *testing.T) { + tests := []struct { + path string + basePath string + want bool + }{ + // known routes + {"/", "/", true}, + {"/containers", "/", true}, + {"/containers/", "/", true}, + {"/Containers", "/", true}, + {"/smart", "/", true}, + {"/monitors", "/", true}, + {"/forgot-password", "/", true}, + {"/request-otp", "/", true}, + {"/system/abc123", "/", true}, + {"/system/abc123/", "/", true}, + {"/settings", "/", true}, + {"/settings/general", "/", true}, + + // unknown paths + {"/.env", "/", false}, + {"/phpinfo.php", "/", false}, + {"/wp-admin/", "/", false}, + {"/.git/config", "/", false}, + {"/system", "/", false}, + {"/system/", "/", false}, + {"/system/abc/def", "/", false}, + {"/settings/general/extra", "/", false}, + {"/containersx", "/", false}, + + // base path, prefix not stripped by proxy + {"/beszel", "/beszel/", true}, + {"/beszel/", "/beszel/", true}, + {"/beszel/containers", "/beszel/", true}, + {"/beszel/system/abc123", "/beszel/", true}, + {"/beszel/.env", "/beszel/", false}, + {"/beszelx", "/beszel/", false}, + + // base path, prefix stripped by proxy + {"/", "/beszel/", true}, + {"/containers", "/beszel/", true}, + {"/.env", "/beszel/", false}, + } + + for _, tt := range tests { + assert.Equal(t, tt.want, isAppRoute(tt.path, tt.basePath), "path=%q basePath=%q", tt.path, tt.basePath) + } +}