Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -2692,6 +2692,12 @@
" 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\");",
"});",
"",
"pm.test(\"Explicit false TLS settings remain false when present\", function () {",
" var jsonData = {};",
" try {",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8423,6 +8423,12 @@
" 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\");",
"});",
"",
"pm.test(\"Explicit false TLS settings remain false when present\", function () {",
" var jsonData = {};",
" try {",
Expand Down
24 changes: 24 additions & 0 deletions internal/controller/httpapi/router.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package httpapi

import (
"net/http"
"strings"

"github.com/gin-gonic/gin"
"github.com/gin-gonic/gin/binding"
Expand All @@ -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
Expand Down
28 changes: 23 additions & 5 deletions internal/controller/httpapi/ui.go
Original file line number Diff line number Diff line change
Expand Up @@ -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("./", http.FS(staticFiles))
})
Comment thread
sudhir-intc marked this conversation as resolved.

modifiedMainJS := injectConfigToMainJS(l, cfg)
handler.StaticFile("/main.js", modifiedMainJS)
Expand Down Expand Up @@ -80,6 +83,7 @@ func setupUIRoutes(handler *gin.Engine, l logger.Interface, cfg *config.Config)
return
}

setNoCacheHeaders(c)
c.FileFromFS("./", http.FS(staticFiles))
})
}
Expand Down Expand Up @@ -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.
Expand Down
118 changes: 118 additions & 0 deletions internal/controller/httpapi/ui_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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"))
}
Loading