From 3569ae136ab58f3262240edcaf7173e3c735f3e4 Mon Sep 17 00:00:00 2001 From: CJACK Date: Sun, 10 May 2026 18:55:57 +0800 Subject: [PATCH] fix webui static root path guard --- internal/webui/handler.go | 16 +++++++++++++++- internal/webui/handler_test.go | 19 +++++++++++++++++++ 2 files changed, 34 insertions(+), 1 deletion(-) diff --git a/internal/webui/handler.go b/internal/webui/handler.go index 5742bb4..8e777b9 100644 --- a/internal/webui/handler.go +++ b/internal/webui/handler.go @@ -100,7 +100,7 @@ func (h *Handler) serveFromDisk(w http.ResponseWriter, r *http.Request, staticDi path = strings.TrimPrefix(path, "/") if path != "" && strings.Contains(path, ".") { full := filepath.Join(root, filepath.Clean(path)) - if full != root && !strings.HasPrefix(full, root+string(os.PathSeparator)) { + if !isPathInsideRoot(full, root) { http.NotFound(w, r) return } @@ -127,6 +127,20 @@ func (h *Handler) serveFromDisk(w http.ResponseWriter, r *http.Request, staticDi http.ServeFile(w, r, index) } +func isPathInsideRoot(path, root string) bool { + cleanPath := filepath.Clean(path) + cleanRoot := filepath.Clean(root) + if cleanPath == cleanRoot { + return true + } + volume := filepath.VolumeName(cleanRoot) + rootWithoutVolume := cleanRoot[len(volume):] + if rootWithoutVolume == string(os.PathSeparator) { + return strings.HasPrefix(cleanPath, cleanRoot) + } + return strings.HasPrefix(cleanPath, cleanRoot+string(os.PathSeparator)) +} + func resolveStaticAdminDir(preferred string) string { if strings.TrimSpace(os.Getenv("DS2API_STATIC_ADMIN_DIR")) != "" { return filepath.Clean(preferred) diff --git a/internal/webui/handler_test.go b/internal/webui/handler_test.go index a819d8c..5e07bf5 100644 --- a/internal/webui/handler_test.go +++ b/internal/webui/handler_test.go @@ -105,6 +105,25 @@ func TestServeFromDiskRejectsSiblingDirectoryWithSharedPrefix(t *testing.T) { } } +func TestIsPathInsideRootAllowsFilesystemRootChildren(t *testing.T) { + root := filepath.VolumeName(os.TempDir()) + string(os.PathSeparator) + child := filepath.Join(root, "assets", "index.css") + + if !isPathInsideRoot(child, root) { + t.Fatalf("expected filesystem-root child %q inside %q", child, root) + } +} + +func TestIsPathInsideRootRejectsSharedPrefixSibling(t *testing.T) { + parent := t.TempDir() + root := filepath.Join(parent, "admin") + sibling := filepath.Join(parent, "admin-leak", "secret.txt") + + if isPathInsideRoot(sibling, root) { + t.Fatalf("expected shared-prefix sibling %q outside %q", sibling, root) + } +} + // TestSetStaticContentTypeUnknownExtensionFallsThrough verifies that unknown // extensions leave the Content-Type header unset, so http.ServeFile can apply // its own detection (sniffing or mime.TypeByExtension) for cases the pinned