From b76d3334cb9a12e2bcceca196510363c2c6b97e2 Mon Sep 17 00:00:00 2001 From: DevipriyaS17 Date: Wed, 12 Aug 2026 12:02:39 +0530 Subject: [PATCH 1/2] fix: prevent browser caching --- internal/controller/httpapi/router.go | 24 +++++ internal/controller/httpapi/ui.go | 30 +++++-- internal/controller/httpapi/ui_test.go | 118 +++++++++++++++++++++++++ 3 files changed, 166 insertions(+), 6 deletions(-) diff --git a/internal/controller/httpapi/router.go b/internal/controller/httpapi/router.go index 3a36681c9..4988fc5f5 100644 --- a/internal/controller/httpapi/router.go +++ b/internal/controller/httpapi/router.go @@ -3,6 +3,7 @@ package httpapi import ( "net/http" + "strings" "github.com/gin-gonic/gin" "github.com/gin-gonic/gin/binding" @@ -19,11 +20,34 @@ import ( "github.com/device-management-toolkit/console/pkg/logger" ) +const ( + cacheControlNoStore = "no-cache, no-store, must-revalidate" + pragmaNoCache = "no-cache" + expiresNoCache = "0" +) + +func setNoCacheHeaders(c *gin.Context) { + c.Header("Cache-Control", cacheControlNoStore) + c.Header("Pragma", pragmaNoCache) + c.Header("Expires", expiresNoCache) +} + +func noCacheHeadersMiddleware() gin.HandlerFunc { + return func(c *gin.Context) { + if c.Request != nil && strings.HasPrefix(c.Request.URL.Path, "/api") { + setNoCacheHeaders(c) + } + + c.Next() + } +} + // NewRouter -. func NewRouter(handler *gin.Engine, l logger.Interface, t usecase.Usecases, cfg *config.Config) { // Options handler.Use(gin.Logger()) handler.Use(gin.Recovery()) + handler.Use(noCacheHeadersMiddleware()) // Add Prometheus middleware for automatic HTTP metrics // Don't automatically register /metrics endpoint - we have our own diff --git a/internal/controller/httpapi/ui.go b/internal/controller/httpapi/ui.go index c747c9ae6..205e61609 100644 --- a/internal/controller/httpapi/ui.go +++ b/internal/controller/httpapi/ui.go @@ -42,7 +42,10 @@ func setupUIRoutes(handler *gin.Engine, l logger.Interface, cfg *config.Config) l.Fatal(err) } - handler.StaticFileFS("/", "./", http.FS(staticFiles)) // Serve static files from "/" route + handler.GET("/", func(c *gin.Context) { + setNoCacheHeaders(c) + c.FileFromFS("index.html", http.FS(staticFiles)) + }) modifiedMainJS := injectConfigToMainJS(l, cfg) handler.StaticFile("/main.js", modifiedMainJS) @@ -80,7 +83,8 @@ func setupUIRoutes(handler *gin.Engine, l logger.Interface, cfg *config.Config) return } - c.FileFromFS("./", http.FS(staticFiles)) + setNoCacheHeaders(c) + c.FileFromFS("index.html", http.FS(staticFiles)) }) } @@ -126,16 +130,30 @@ func injectConfigToMainJS(l logger.Interface, cfg *config.Config) string { "##CONSOLE_SERVER_API##": consoleServerAPIBase(protocol, cfg.Host, cfg.Port), }) - // Write to /tmp permissions := 0o600 - tempFile := filepath.Join(os.TempDir(), "main.js") + tempMainJS, err := os.CreateTemp(os.TempDir(), "main-*.js") + if err != nil { + log.Fatalf("Could not create temp main.js: %v", err) + } + + if err := tempMainJS.Chmod(os.FileMode(permissions)); err != nil { + _ = tempMainJS.Close() + + log.Fatalf("Could not set modified main.js permissions: %v", err) + } + + if _, err := tempMainJS.Write(data); err != nil { + _ = tempMainJS.Close() - if err := os.WriteFile(tempFile, data, os.FileMode(permissions)); err != nil { log.Fatalf("Could not write modified main.js: %v", err) } - return tempFile + if err := tempMainJS.Close(); err != nil { + log.Fatalf("Could not finalize modified main.js: %v", err) + } + + return filepath.Clean(tempMainJS.Name()) } // Returns "" on wildcard hosts so the UI uses same-origin requests matching the user's URL/SNI. diff --git a/internal/controller/httpapi/ui_test.go b/internal/controller/httpapi/ui_test.go index 251362786..0f1d77581 100644 --- a/internal/controller/httpapi/ui_test.go +++ b/internal/controller/httpapi/ui_test.go @@ -3,9 +3,16 @@ package httpapi import ( + "net/http" + "net/http/httptest" "testing" + "github.com/gin-gonic/gin" "github.com/stretchr/testify/require" + + "github.com/device-management-toolkit/console/config" + "github.com/device-management-toolkit/console/internal/usecase" + "github.com/device-management-toolkit/console/pkg/logger" ) func TestConsoleServerAPIBase(t *testing.T) { @@ -78,3 +85,114 @@ func TestConsoleServerAPIBase(t *testing.T) { }) } } + +func TestUIFallbackAddsNoCacheHeaders(t *testing.T) { + t.Parallel() + + engine := gin.New() + setupUIRoutes(engine, logger.New("error"), &config.Config{}) + + req := httptest.NewRequest(http.MethodGet, "/random-non-asset-route", http.NoBody) + w := httptest.NewRecorder() + + engine.ServeHTTP(w, req) + + require.Contains(t, []int{http.StatusOK, http.StatusMovedPermanently, http.StatusNotFound}, w.Code) + require.Equal(t, cacheControlNoStore, w.Header().Get("Cache-Control")) + require.Equal(t, pragmaNoCache, w.Header().Get("Pragma")) + require.Equal(t, expiresNoCache, w.Header().Get("Expires")) +} + +func TestUIRootAddsNoCacheHeaders(t *testing.T) { + t.Parallel() + + engine := gin.New() + setupUIRoutes(engine, logger.New("error"), &config.Config{}) + + req := httptest.NewRequest(http.MethodGet, "/", http.NoBody) + w := httptest.NewRecorder() + + engine.ServeHTTP(w, req) + + require.Contains(t, []int{http.StatusOK, http.StatusMovedPermanently, http.StatusNotFound}, w.Code) + require.Equal(t, cacheControlNoStore, w.Header().Get("Cache-Control")) + require.Equal(t, pragmaNoCache, w.Header().Get("Pragma")) + require.Equal(t, expiresNoCache, w.Header().Get("Expires")) +} + +func TestUIAssetsNoRouteReturns404WithoutNoCacheHeaders(t *testing.T) { + t.Parallel() + + engine := gin.New() + setupUIRoutes(engine, logger.New("error"), &config.Config{}) + + req := httptest.NewRequest(http.MethodGet, "/assets/does-not-exist", http.NoBody) + w := httptest.NewRecorder() + + engine.ServeHTTP(w, req) + + require.Equal(t, http.StatusNotFound, w.Code) + require.Empty(t, w.Header().Get("Cache-Control")) + require.Empty(t, w.Header().Get("Pragma")) + require.Empty(t, w.Header().Get("Expires")) +} + +func TestNoCacheHeadersMiddleware(t *testing.T) { + t.Parallel() + + engine := gin.New() + engine.Use(noCacheHeadersMiddleware()) + engine.GET("/api/v1/test", func(c *gin.Context) { + c.JSON(http.StatusOK, gin.H{"ok": true}) + }) + + req := httptest.NewRequest(http.MethodGet, "/api/v1/test", http.NoBody) + w := httptest.NewRecorder() + + engine.ServeHTTP(w, req) + + require.Equal(t, cacheControlNoStore, w.Header().Get("Cache-Control")) + require.Equal(t, pragmaNoCache, w.Header().Get("Pragma")) + require.Equal(t, expiresNoCache, w.Header().Get("Expires")) +} + +func TestNoCacheHeadersMiddlewareSkipsNonAPIPaths(t *testing.T) { + t.Parallel() + + engine := gin.New() + engine.Use(noCacheHeadersMiddleware()) + engine.GET("/healthz", func(c *gin.Context) { + c.Status(http.StatusOK) + }) + + req := httptest.NewRequest(http.MethodGet, "/healthz", http.NoBody) + w := httptest.NewRecorder() + + engine.ServeHTTP(w, req) + + require.Equal(t, http.StatusOK, w.Code) + require.Empty(t, w.Header().Get("Cache-Control")) + require.Empty(t, w.Header().Get("Pragma")) + require.Empty(t, w.Header().Get("Expires")) +} + +//nolint:paralleltest // mutates shared global config.ConsoleConfig +func TestNewRouterAuthorizeRouteHasNoCacheHeaders(t *testing.T) { + prev := config.ConsoleConfig + config.ConsoleConfig = &config.Config{} + + t.Cleanup(func() { config.ConsoleConfig = prev }) + + engine := gin.New() + NewRouter(engine, logger.New("error"), usecase.Usecases{}, &config.Config{}) + + req := httptest.NewRequest(http.MethodPost, "/api/v1/authorize", http.NoBody) + w := httptest.NewRecorder() + + engine.ServeHTTP(w, req) + + require.Equal(t, http.StatusBadRequest, w.Code) + require.Equal(t, cacheControlNoStore, w.Header().Get("Cache-Control")) + require.Equal(t, pragmaNoCache, w.Header().Get("Pragma")) + require.Equal(t, expiresNoCache, w.Header().Get("Expires")) +} From 85fab690830736caadcdbd367cfe5f51cc519c40 Mon Sep 17 00:00:00 2001 From: DevipriyaS17 Date: Fri, 14 Aug 2026 22:11:57 +0530 Subject: [PATCH 2/2] fix: address review comments --- .../collections/console_mps_apis.postman_collection.json | 6 ++++++ .../collections/console_rps_apis.postman_collection.json | 6 ++++++ internal/controller/httpapi/ui.go | 4 ++-- 3 files changed, 14 insertions(+), 2 deletions(-) diff --git a/integration-test/collections/console_mps_apis.postman_collection.json b/integration-test/collections/console_mps_apis.postman_collection.json index 5de19d307..53aee7829 100644 --- a/integration-test/collections/console_mps_apis.postman_collection.json +++ b/integration-test/collections/console_mps_apis.postman_collection.json @@ -2688,6 +2688,12 @@ "exec": [ "pm.test(\"Response includes X-Content-Type-Options: nosniff\", function () {", " pm.expect(pm.response.headers.get(\"X-Content-Type-Options\")).to.eql(\"nosniff\");", + "});", + "", + "pm.test(\"Response disables caching\", function () {", + " pm.expect(pm.response.headers.get(\"Cache-Control\")).to.eql(\"no-cache, no-store, must-revalidate\");", + " pm.expect(pm.response.headers.get(\"Pragma\")).to.eql(\"no-cache\");", + " pm.expect(pm.response.headers.get(\"Expires\")).to.eql(\"0\");", "});" ] } diff --git a/integration-test/collections/console_rps_apis.postman_collection.json b/integration-test/collections/console_rps_apis.postman_collection.json index 4309cf9b1..a4322d600 100644 --- a/integration-test/collections/console_rps_apis.postman_collection.json +++ b/integration-test/collections/console_rps_apis.postman_collection.json @@ -8421,6 +8421,12 @@ "exec": [ "pm.test(\"Response includes X-Content-Type-Options: nosniff\", function () {", " pm.expect(pm.response.headers.get(\"X-Content-Type-Options\")).to.eql(\"nosniff\");", + "});", + "", + "pm.test(\"Response disables caching\", function () {", + " pm.expect(pm.response.headers.get(\"Cache-Control\")).to.eql(\"no-cache, no-store, must-revalidate\");", + " pm.expect(pm.response.headers.get(\"Pragma\")).to.eql(\"no-cache\");", + " pm.expect(pm.response.headers.get(\"Expires\")).to.eql(\"0\");", "});" ] } diff --git a/internal/controller/httpapi/ui.go b/internal/controller/httpapi/ui.go index 205e61609..84857fb29 100644 --- a/internal/controller/httpapi/ui.go +++ b/internal/controller/httpapi/ui.go @@ -44,7 +44,7 @@ func setupUIRoutes(handler *gin.Engine, l logger.Interface, cfg *config.Config) handler.GET("/", func(c *gin.Context) { setNoCacheHeaders(c) - c.FileFromFS("index.html", http.FS(staticFiles)) + c.FileFromFS("./", http.FS(staticFiles)) }) modifiedMainJS := injectConfigToMainJS(l, cfg) @@ -84,7 +84,7 @@ func setupUIRoutes(handler *gin.Engine, l logger.Interface, cfg *config.Config) } setNoCacheHeaders(c) - c.FileFromFS("index.html", http.FS(staticFiles)) + c.FileFromFS("./", http.FS(staticFiles)) }) }