diff --git a/backend/internal/bootstrap/router_bootstrap.go b/backend/internal/bootstrap/router_bootstrap.go index 3c1072be..c839e42f 100644 --- a/backend/internal/bootstrap/router_bootstrap.go +++ b/backend/internal/bootstrap/router_bootstrap.go @@ -63,6 +63,7 @@ func initRouterInternal(db *gorm.DB, svc *services) (utils.Service, error) { rateLimitMiddleware := middleware.NewRateLimitMiddleware().Add(rate.Every(time.Second), 60) // Setup global middleware + r.Use(middleware.NewCacheControlMiddleware().Add()) r.Use(middleware.NewCorsMiddleware().Add()) r.Use(middleware.NewCspMiddleware().Add()) r.Use(middleware.NewErrorHandlerMiddleware().Add()) diff --git a/backend/internal/middleware/cache_control.go b/backend/internal/middleware/cache_control.go new file mode 100644 index 00000000..f18974db --- /dev/null +++ b/backend/internal/middleware/cache_control.go @@ -0,0 +1,26 @@ +package middleware + +import "github.com/gin-gonic/gin" + +// CacheControlMiddleware sets a safe default Cache-Control header on responses +// that do not already specify one. This prevents proxies from caching +// authenticated responses that might contain private data. +type CacheControlMiddleware struct { + headerValue string +} + +func NewCacheControlMiddleware() *CacheControlMiddleware { + return &CacheControlMiddleware{ + headerValue: "private, no-store", + } +} + +func (m *CacheControlMiddleware) Add() gin.HandlerFunc { + return func(c *gin.Context) { + if c.Writer.Header().Get("Cache-Control") == "" { + c.Header("Cache-Control", m.headerValue) + } + + c.Next() + } +} diff --git a/backend/internal/middleware/cache_control_test.go b/backend/internal/middleware/cache_control_test.go new file mode 100644 index 00000000..3f76fb0b --- /dev/null +++ b/backend/internal/middleware/cache_control_test.go @@ -0,0 +1,45 @@ +package middleware + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/require" +) + +func TestCacheControlMiddlewareSetsDefault(t *testing.T) { + gin.SetMode(gin.TestMode) + router := gin.New() + router.Use(NewCacheControlMiddleware().Add()) + + router.GET("/test", func(c *gin.Context) { + c.Status(http.StatusOK) + }) + + req := httptest.NewRequest(http.MethodGet, "/test", http.NoBody) + w := httptest.NewRecorder() + + router.ServeHTTP(w, req) + + require.Equal(t, "private, no-store", w.Header().Get("Cache-Control")) +} + +func TestCacheControlMiddlewarePreservesExistingHeader(t *testing.T) { + gin.SetMode(gin.TestMode) + router := gin.New() + router.Use(NewCacheControlMiddleware().Add()) + + router.GET("/custom", func(c *gin.Context) { + c.Header("Cache-Control", "public, max-age=60") + c.Status(http.StatusOK) + }) + + req := httptest.NewRequest(http.MethodGet, "/custom", http.NoBody) + w := httptest.NewRecorder() + + router.ServeHTTP(w, req) + + require.Equal(t, "public, max-age=60", w.Header().Get("Cache-Control")) +}