From 40d286cbb4cef107df778ebc9bd50375fbe12067 Mon Sep 17 00:00:00 2001
From: Nico <32375741+nulmete@users.noreply.github.com>
Date: Tue, 30 Jun 2026 10:13:07 -0300
Subject: [PATCH] Add Cache-Control to static assets served under /assets/
(#48409)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
**Related issue:** Resolves #45682
# Checklist for submitter
- [x] Changes file added for user-visible changes in `changes/`,
`orbit/changes/` or `ee/fleetd-chrome/changes`.
See [Changes
files](https://github.com/fleetdm/fleet/blob/main/docs/Contributing/guides/committing-changes.md#changes-files)
for more information.
## Testing
- [x] Added/updated automated tests
- [x] QA'd all new/changed functionality manually
#### Before
#### After
## Summary by CodeRabbit
* **New Features**
* Content-hashed static assets under `/assets/` (e.g., hashed JS/CSS,
images, fonts) now use long-lived, immutable `Cache-Control` to improve
repeat page loads.
* **Bug Fixes**
* `Cache-Control` is now applied consistently for successful responses
and `304 Not Modified`.
* Non-hashed assets and non-success/error responses correctly avoid
caching via `Cache-Control: no-cache`.
* **Documentation**
* Added a release note explaining the new `Cache-Control` behavior for
hashed assets.
---
changes/45682-cache-control-static-assets | 1 +
server/service/frontend.go | 47 ++++++++++++++++++++++-
server/service/frontend_test.go | 42 ++++++++++++++++++++
3 files changed, 89 insertions(+), 1 deletion(-)
create mode 100644 changes/45682-cache-control-static-assets
diff --git a/changes/45682-cache-control-static-assets b/changes/45682-cache-control-static-assets
new file mode 100644
index 0000000000..600e928297
--- /dev/null
+++ b/changes/45682-cache-control-static-assets
@@ -0,0 +1 @@
+* Added a long-lived immutable `Cache-Control` header to content-hashed static assets under `/assets/` so browsers and CDNs can cache them across loads instead of refetching the JS/CSS bundle from origin every time.
diff --git a/server/service/frontend.go b/server/service/frontend.go
index 4aa91f8887..717992f14b 100644
--- a/server/service/frontend.go
+++ b/server/service/frontend.go
@@ -8,6 +8,7 @@ import (
"log/slog"
"net/http"
"net/url"
+ "regexp"
assetfs "github.com/elazarl/go-bindata-assetfs"
shared_mdm "github.com/fleetdm/fleet/v4/pkg/mdm"
@@ -273,10 +274,16 @@ func initiateOTAEnrollSSO(svc fleet.Service, w http.ResponseWriter, r *http.Requ
return nil
}
+// hashedAssetRe matches build-output filenames that embed a content hash, e.g.
+// "bundle-3ccf015bc0fac64b4ce8.js" or "logo@1a2b3c4d.png". A content change
+// produces a new hash and therefore a new URL, so these are safe to cache
+// forever. Unhashed names (dev builds like "bundle.js") must keep revalidating.
+var hashedAssetRe = regexp.MustCompile(`[-@][0-9a-f]{8,}\.[a-z0-9]+$`)
+
func ServeStaticAssets(path string, serveCSP bool) http.Handler {
contentTypes := []string{"text/javascript", "text/css"}
staticAssetsServer := endpointer.BrowserSecurityHeadersHandler(serveCSP, http.FileServer(newBinaryFileSystem("/assets")))
- withoutGzip := http.StripPrefix(path, staticAssetsServer)
+ withoutGzip := http.StripPrefix(path, assetCacheControl(staticAssetsServer))
withOpts, err := gzhttp.NewWrapper(gzhttp.ContentTypes(contentTypes))
if err != nil { // fall back to serving without gzip if serving with gzip somehow fails
@@ -285,3 +292,41 @@ func ServeStaticAssets(path string, serveCSP bool) http.Handler {
return withOpts(withoutGzip)
}
+
+func assetCacheControl(next http.Handler) http.Handler {
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ next.ServeHTTP(&cacheControlResponseWriter{ResponseWriter: w, path: r.URL.Path}, r)
+ })
+}
+
+type cacheControlResponseWriter struct {
+ http.ResponseWriter
+ path string
+ wroteHeader bool
+}
+
+// WriteHeader decides Cache-Control from the final status so the long-lived
+// immutable cache is applied only to successful responses for hashed assets.
+// Caching a transient 404/500 would otherwise pin a broken asset at the browser/CDN.
+func (w *cacheControlResponseWriter) WriteHeader(status int) {
+ if !w.wroteHeader {
+ w.wroteHeader = true
+ if (status == http.StatusOK || status == http.StatusNotModified) && hashedAssetRe.MatchString(w.path) {
+ w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
+ } else {
+ w.Header().Set("Cache-Control", "no-cache")
+ }
+ }
+ w.ResponseWriter.WriteHeader(status)
+}
+
+func (w *cacheControlResponseWriter) Write(b []byte) (int, error) {
+ if !w.wroteHeader {
+ w.WriteHeader(http.StatusOK)
+ }
+ return w.ResponseWriter.Write(b)
+}
+
+func (w *cacheControlResponseWriter) Unwrap() http.ResponseWriter {
+ return w.ResponseWriter
+}
diff --git a/server/service/frontend_test.go b/server/service/frontend_test.go
index f66624eec4..4fe045fd3d 100644
--- a/server/service/frontend_test.go
+++ b/server/service/frontend_test.go
@@ -49,6 +49,48 @@ func TestServeFrontend(t *testing.T) {
require.Equal(t, http.StatusMethodNotAllowed, response.StatusCode)
}
+func TestAssetCacheControl(t *testing.T) {
+ for _, tc := range []struct {
+ path string
+ status int
+ want string
+ }{
+ // Content-hashed build output (JS/CSS, fonts, images) is safe to cache
+ // forever — webpack emits everything as [name]@[hash][ext].
+ {"/bundle-3ccf015bc0fac64b4ce8.js", http.StatusOK, "public, max-age=31536000, immutable"},
+ {"/bundle-1e51316ac7963e1112c1.css", http.StatusOK, "public, max-age=31536000, immutable"},
+ {"/Inter-Bold@1a2b3c4d5e6f7890.woff2", http.StatusOK, "public, max-age=31536000, immutable"},
+ {"/404-dark@1a2b3c4d5e6f7890.svg", http.StatusOK, "public, max-age=31536000, immutable"},
+ {"/jira-preview-400x419@2x@1a2b3c4d5e6f7890.png", http.StatusOK, "public, max-age=31536000, immutable"},
+ // A 304 keeps the immutable header (the cached copy is still valid).
+ {"/bundle-3ccf015bc0fac64b4ce8.js", http.StatusNotModified, "public, max-age=31536000, immutable"},
+ // A missing/errored hashed asset (deploy race) must NOT be cached for a
+ // year, or a transient failure would pin a broken asset at the CDN/browser.
+ {"/bundle-deadbeefdeadbeef.js", http.StatusNotFound, "no-cache"},
+ {"/Inter-Bold@deadbeef12345678.woff2", http.StatusInternalServerError, "no-cache"},
+ // Unhashed (dev builds, favicon, static scripts) must keep revalidating.
+ {"/bundle.js", http.StatusOK, "no-cache"},
+ {"/bundle.css", http.StatusOK, "no-cache"},
+ {"/favicon.ico", http.StatusOK, "no-cache"},
+ // status 0 = handler writes a body without calling WriteHeader, exercising
+ // the implicit-200 path in cacheControlResponseWriter.Write.
+ {"/bundle-3ccf015bc0fac64b4ce8.js", 0, "public, max-age=31536000, immutable"},
+ } {
+ t.Run(fmt.Sprintf("%s_%d", tc.path, tc.status), func(t *testing.T) {
+ handler := assetCacheControl(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if tc.status == 0 {
+ _, _ = w.Write([]byte("body"))
+ return
+ }
+ w.WriteHeader(tc.status)
+ }))
+ rec := httptest.NewRecorder()
+ handler.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, tc.path, nil))
+ require.Equal(t, tc.want, rec.Header().Get("Cache-Control"))
+ })
+ }
+}
+
func TestServeEndUserEnrollOTA(t *testing.T) {
if !hasBuildTag("full") {
t.Skip("This test requires running with -tags full")