diff --git a/pkg/server/middleware.go b/pkg/server/middleware.go index 123286f1be4..0f014498151 100644 --- a/pkg/server/middleware.go +++ b/pkg/server/middleware.go @@ -136,6 +136,16 @@ func securityHeadersMiddleware(hdlr http.Handler) http.HandlerFunc { w.Header().Set("X-DNS-Prefetch-Control", "off") // Less information leakage about what domains we link to w.Header().Set("Referrer-Policy", "strict-origin-when-cross-origin") + w.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate") + w.Header().Set("Pragma", "no-cache") + hdlr.ServeHTTP(w, r) + } +} + +func staticCacheHeaders(hdlr http.Handler) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Cache-Control", "public, max-age=31536000, immutable") + w.Header().Del("Pragma") hdlr.ServeHTTP(w, r) } } diff --git a/pkg/server/middleware_test.go b/pkg/server/middleware_test.go new file mode 100644 index 00000000000..b4ae302bb41 --- /dev/null +++ b/pkg/server/middleware_test.go @@ -0,0 +1,75 @@ +package server + +import ( + "net/http" + "net/http/httptest" + "testing" +) + +func TestSecurityHeadersMiddleware(t *testing.T) { + inner := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + }) + handler := securityHeadersMiddleware(inner) + + req := httptest.NewRequest(http.MethodGet, "/", nil) + rr := httptest.NewRecorder() + handler.ServeHTTP(rr, req) + + expected := map[string]string{ + "X-Content-Type-Options": "nosniff", + "X-Frame-Options": "DENY", + "X-DNS-Prefetch-Control": "off", + "Referrer-Policy": "strict-origin-when-cross-origin", + "Cache-Control": "no-cache, no-store, must-revalidate", + "Pragma": "no-cache", + } + + for header, want := range expected { + got := rr.Header().Get(header) + if got != want { + t.Errorf("header %s = %q, want %q", header, got, want) + } + } +} + +func TestStaticCacheHeaders(t *testing.T) { + inner := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + }) + // Simulate the real chain: securityHeadersMiddleware sets restrictive defaults, + // then staticCacheHeaders overrides them for static assets. + handler := securityHeadersMiddleware(staticCacheHeaders(inner)) + + req := httptest.NewRequest(http.MethodGet, "/static/main-bundle-abc123.min.js", nil) + rr := httptest.NewRecorder() + handler.ServeHTTP(rr, req) + + wantCache := "public, max-age=31536000, immutable" + if got := rr.Header().Get("Cache-Control"); got != wantCache { + t.Errorf("Cache-Control = %q, want %q", got, wantCache) + } + if got := rr.Header().Get("Pragma"); got != "" { + t.Errorf("Pragma = %q, want empty (deleted)", got) + } +} + +func TestNonStaticEndpointHasNoCacheHeaders(t *testing.T) { + inner := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + }) + handler := securityHeadersMiddleware(inner) + + req := httptest.NewRequest(http.MethodGet, "/api/console/version", nil) + rr := httptest.NewRecorder() + handler.ServeHTTP(rr, req) + + wantCache := "no-cache, no-store, must-revalidate" + if got := rr.Header().Get("Cache-Control"); got != wantCache { + t.Errorf("Cache-Control = %q, want %q", got, wantCache) + } + wantPragma := "no-cache" + if got := rr.Header().Get("Pragma"); got != wantPragma { + t.Errorf("Pragma = %q, want %q", got, wantPragma) + } +} diff --git a/pkg/server/server.go b/pkg/server/server.go index 93d21e4cd04..50f98339837 100644 --- a/pkg/server/server.go +++ b/pkg/server/server.go @@ -296,7 +296,7 @@ func (s *Server) HTTPHandler() (http.Handler, error) { handleFunc("/api/", notFoundHandler) staticHandler := http.StripPrefix(proxy.SingleJoiningSlash(s.BaseURL.Path, "/static/"), disableDirectoryListing(http.FileServer(http.Dir(s.PublicDir)))) - handle("/static/", gzipHandler(securityHeadersMiddleware(staticHandler))) + handle("/static/", gzipHandler(staticCacheHeaders(staticHandler))) if s.CustomLogoFile != "" { handleFunc(customLogoEndpoint, func(w http.ResponseWriter, r *http.Request) {