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.
This commit is contained in:
lin
2026-05-04 10:09:50 +08:00
parent c32fe30239
commit 7870a61bb0
2 changed files with 143 additions and 0 deletions

View File

@@ -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)
}

View File

@@ -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": "<!doctype html><html></html>",
"assets/index.css": "body{}",
"assets/index.js": "console.log(1)",
"assets/icon.svg": `<svg xmlns="http://www.w3.org/2000/svg"></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)
}
}