From 7870a61bb09faf5a99c28a6d43a43256ab79f245 Mon Sep 17 00:00:00 2001 From: lin <94952195+lwz762@users.noreply.github.com> Date: Mon, 4 May 2026 10:09:50 +0800 Subject: [PATCH] fix(webui): pin Content-Type for /admin static assets http.ServeFile relies on mime.TypeByExtension, which on Windows reads HKEY_CLASSES_ROOT to resolve the MIME type. Third-party software (some editors and registry-cleaning tools) can rewrite ".css" to "application/xml", causing Chrome to refuse the stylesheet and breaking the /admin panel with a fully unstyled page. The same class of bug affects ".js" -> "text/plain" in some setups. Pin the Content-Type by file extension before delegating to ServeFile, covering the WebUI asset surface (css, js, mjs, html, json, map, svg, common image and font formats, wasm). Unknown extensions still fall through to ServeFile's default detection. Tests cover the pinned types, case-insensitive extension matching, and the unknown-extension passthrough. --- internal/webui/handler.go | 41 +++++++++++++ internal/webui/handler_test.go | 102 +++++++++++++++++++++++++++++++++ 2 files changed, 143 insertions(+) create mode 100644 internal/webui/handler_test.go diff --git a/internal/webui/handler.go b/internal/webui/handler.go index ade79e4..da9649d 100644 --- a/internal/webui/handler.go +++ b/internal/webui/handler.go @@ -55,6 +55,45 @@ func (h *Handler) admin(w http.ResponseWriter, r *http.Request) { http.Error(w, "WebUI not built. Run `cd webui && npm run build` first.", http.StatusNotFound) } +// staticContentTypes pins the Content-Type of common WebUI assets so we do not +// rely on mime.TypeByExtension, which on Windows consults the registry and can +// return the wrong type (e.g. application/xml for .css) when third-party +// software has overwritten HKEY_CLASSES_ROOT entries. Browsers strictly enforce +// stylesheet/script MIME types and will refuse to apply a misidentified asset, +// breaking the /admin page on affected machines. +var staticContentTypes = map[string]string{ + ".css": "text/css; charset=utf-8", + ".js": "text/javascript; charset=utf-8", + ".mjs": "text/javascript; charset=utf-8", + ".html": "text/html; charset=utf-8", + ".htm": "text/html; charset=utf-8", + ".json": "application/json; charset=utf-8", + ".map": "application/json; charset=utf-8", + ".svg": "image/svg+xml", + ".png": "image/png", + ".jpg": "image/jpeg", + ".jpeg": "image/jpeg", + ".gif": "image/gif", + ".webp": "image/webp", + ".ico": "image/x-icon", + ".woff": "font/woff", + ".woff2": "font/woff2", + ".ttf": "font/ttf", + ".otf": "font/otf", + ".txt": "text/plain; charset=utf-8", + ".wasm": "application/wasm", +} + +// setStaticContentType pins the response Content-Type by file extension so that +// http.ServeFile does not fall back to mime.TypeByExtension (which on Windows +// reads the registry and may return an incorrect type). +func setStaticContentType(w http.ResponseWriter, fullPath string) { + ext := strings.ToLower(filepath.Ext(fullPath)) + if ct, ok := staticContentTypes[ext]; ok { + w.Header().Set("Content-Type", ct) + } +} + func (h *Handler) serveFromDisk(w http.ResponseWriter, r *http.Request, staticDir string) { path := strings.TrimPrefix(r.URL.Path, "/admin") path = strings.TrimPrefix(path, "/") @@ -70,6 +109,7 @@ func (h *Handler) serveFromDisk(w http.ResponseWriter, r *http.Request, staticDi } else { w.Header().Set("Cache-Control", "no-store, must-revalidate") } + setStaticContentType(w, full) http.ServeFile(w, r, full) return } @@ -82,6 +122,7 @@ func (h *Handler) serveFromDisk(w http.ResponseWriter, r *http.Request, staticDi return } w.Header().Set("Cache-Control", "no-store, must-revalidate") + setStaticContentType(w, index) http.ServeFile(w, r, index) } diff --git a/internal/webui/handler_test.go b/internal/webui/handler_test.go new file mode 100644 index 0000000..99832c5 --- /dev/null +++ b/internal/webui/handler_test.go @@ -0,0 +1,102 @@ +package webui + +import ( + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" +) + +// TestServeFromDiskPinsContentType ensures static admin assets are returned +// with an explicit, RFC-compliant Content-Type that does not depend on +// mime.TypeByExtension. On Windows mime.TypeByExtension consults the registry +// (HKEY_CLASSES_ROOT) which third-party software can corrupt — for example +// installing certain editors rewrites .css to application/xml — and Chrome +// then refuses to apply a stylesheet whose Content-Type is not text/css, +// breaking the /admin page entirely. Pinning the type by file extension makes +// the response deterministic across operating systems and machine state. +func TestServeFromDiskPinsContentType(t *testing.T) { + staticDir := t.TempDir() + assetsDir := filepath.Join(staticDir, "assets") + if err := os.MkdirAll(assetsDir, 0o755); err != nil { + t.Fatalf("mkdir assets: %v", err) + } + + files := map[string]string{ + "index.html": "", + "assets/index.css": "body{}", + "assets/index.js": "console.log(1)", + "assets/icon.svg": ``, + "assets/source.js.map": `{"version":3}`, + } + for rel, body := range files { + full := filepath.Join(staticDir, filepath.FromSlash(rel)) + if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil { + t.Fatalf("mkdir %s: %v", rel, err) + } + if err := os.WriteFile(full, []byte(body), 0o644); err != nil { + t.Fatalf("write %s: %v", rel, err) + } + } + + h := &Handler{StaticDir: staticDir} + + cases := []struct { + urlPath string + wantPrefix string + wantCacheCtl string + }{ + {"/admin/assets/index.css", "text/css", "public, max-age=31536000, immutable"}, + {"/admin/assets/index.js", "text/javascript", "public, max-age=31536000, immutable"}, + {"/admin/assets/icon.svg", "image/svg+xml", "public, max-age=31536000, immutable"}, + {"/admin/assets/source.js.map", "application/json", "public, max-age=31536000, immutable"}, + // "/admin/index.html" is intentionally omitted: http.ServeFile redirects + // requests for index.html to "./", matching Go's net/http behavior. The + // route the SPA actually lands on is "/admin/" below. + {"/admin/", "text/html", "no-store, must-revalidate"}, + } + + for _, tc := range cases { + t.Run(tc.urlPath, func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, tc.urlPath, nil) + rec := httptest.NewRecorder() + h.serveFromDisk(rec, req, staticDir) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200", rec.Code) + } + ct := rec.Header().Get("Content-Type") + if !strings.HasPrefix(ct, tc.wantPrefix) { + t.Fatalf("Content-Type = %q, want prefix %q", ct, tc.wantPrefix) + } + if got := rec.Header().Get("Cache-Control"); got != tc.wantCacheCtl { + t.Fatalf("Cache-Control = %q, want %q", got, tc.wantCacheCtl) + } + }) + } +} + +// 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 +// table does not cover. +func TestSetStaticContentTypeUnknownExtensionFallsThrough(t *testing.T) { + rec := httptest.NewRecorder() + setStaticContentType(rec, "/tmp/data.unknownext") + if got := rec.Header().Get("Content-Type"); got != "" { + t.Fatalf("Content-Type = %q, want empty for unknown extension", got) + } +} + +// TestSetStaticContentTypeIsCaseInsensitive guards against a regression where +// uppercase extensions (e.g. STYLE.CSS shipped from some build pipelines) +// would bypass the pinned table and fall back to the registry on Windows. +func TestSetStaticContentTypeIsCaseInsensitive(t *testing.T) { + rec := httptest.NewRecorder() + setStaticContentType(rec, "/tmp/STYLE.CSS") + if got := rec.Header().Get("Content-Type"); !strings.HasPrefix(got, "text/css") { + t.Fatalf("Content-Type = %q, want text/css prefix", got) + } +}