diff --git a/go.mod b/go.mod index 716e0c64..1b68d447 100644 --- a/go.mod +++ b/go.mod @@ -173,9 +173,9 @@ require ( golang.org/x/text v0.17.0 // indirect golang.org/x/tools v0.24.0 // indirect golang.org/x/xerrors v0.0.0-20240716161551-93cc26a95ae9 // indirect - google.golang.org/genproto v0.0.0-20240808171019-573a1156607a // indirect - google.golang.org/genproto/googleapis/api v0.0.0-20240808171019-573a1156607a // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20240808171019-573a1156607a // indirect + google.golang.org/genproto v0.0.0-20240812133136-8ffd90a71988 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20240812133136-8ffd90a71988 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20240812133136-8ffd90a71988 // indirect google.golang.org/grpc v1.65.0 // indirect google.golang.org/protobuf v1.34.2 // indirect gopkg.in/ini.v1 v1.67.0 // indirect diff --git a/go.sum b/go.sum index b03edd7b..caeab2c1 100644 --- a/go.sum +++ b/go.sum @@ -523,12 +523,12 @@ google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7 google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo= -google.golang.org/genproto v0.0.0-20240808171019-573a1156607a h1:3JVv3Ujh+kGiajpSqHWnbWPuu0nQqMZ3hASNDDF9974= -google.golang.org/genproto v0.0.0-20240808171019-573a1156607a/go.mod h1:7uvplUBj4RjHAxIZ//98LzOvrQ04JBkaixRmCMI29hc= -google.golang.org/genproto/googleapis/api v0.0.0-20240808171019-573a1156607a h1:KyUe15n7B1YCu+kMmPtlXxgkLQbp+Dw0tCRZf9Sd+CE= -google.golang.org/genproto/googleapis/api v0.0.0-20240808171019-573a1156607a/go.mod h1:4+X6GvPs+25wZKbQq9qyAXrwIRExv7w0Ea6MgZLZiDM= -google.golang.org/genproto/googleapis/rpc v0.0.0-20240808171019-573a1156607a h1:EKiZZXueP9/T68B8Nl0GAx9cjbQnCId0yP3qPMgaaHs= -google.golang.org/genproto/googleapis/rpc v0.0.0-20240808171019-573a1156607a/go.mod h1:Ue6ibwXGpU+dqIcODieyLOcgj7z8+IcskoNIgZxtrFY= +google.golang.org/genproto v0.0.0-20240812133136-8ffd90a71988 h1:CT2Thj5AuPV9phrYMtzX11k+XkzMGfRAet42PmoTATM= +google.golang.org/genproto v0.0.0-20240812133136-8ffd90a71988/go.mod h1:7uvplUBj4RjHAxIZ//98LzOvrQ04JBkaixRmCMI29hc= +google.golang.org/genproto/googleapis/api v0.0.0-20240812133136-8ffd90a71988 h1:+/tmTy5zAieooKIXfzDm9KiA3Bv6JBwriRN9LY+yayk= +google.golang.org/genproto/googleapis/api v0.0.0-20240812133136-8ffd90a71988/go.mod h1:4+X6GvPs+25wZKbQq9qyAXrwIRExv7w0Ea6MgZLZiDM= +google.golang.org/genproto/googleapis/rpc v0.0.0-20240812133136-8ffd90a71988 h1:V71AcdLZr2p8dC9dbOIMCpqi4EmRl8wUwnJzXXLmbmc= +google.golang.org/genproto/googleapis/rpc v0.0.0-20240812133136-8ffd90a71988/go.mod h1:Ue6ibwXGpU+dqIcODieyLOcgj7z8+IcskoNIgZxtrFY= google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg= google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY= diff --git a/internal/config/config.go b/internal/config/config.go index 34ecb840..30a549bf 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -147,6 +147,7 @@ var ( ContentSecurityPolicy: "", PermissionsPolicy: "", CrossOriginOpenerPolicy: "", + CacheControl: "", }, Branding: httpd.Branding{}, } @@ -1557,6 +1558,12 @@ func getHTTPDSecurityConfFromEnv(idx int) (httpd.SecurityConf, bool) { //nolint: isSet = true } + cacheControl, ok := os.LookupEnv(fmt.Sprintf("SFTPGO_HTTPD__BINDINGS__%v__SECURITY__CACHE_CONTROL", idx)) + if ok { + result.CacheControl = cacheControl + isSet = true + } + return result, isSet } diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 8c433569..a9965aa9 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -1209,6 +1209,7 @@ func TestHTTPDBindingsFromEnv(t *testing.T) { os.Setenv("SFTPGO_HTTPD__BINDINGS__2__SECURITY__CONTENT_SECURITY_POLICY", "script-src $NONCE") os.Setenv("SFTPGO_HTTPD__BINDINGS__2__SECURITY__PERMISSIONS_POLICY", "fullscreen=(), geolocation=()") os.Setenv("SFTPGO_HTTPD__BINDINGS__2__SECURITY__CROSS_ORIGIN_OPENER_POLICY", "same-origin") + os.Setenv("SFTPGO_HTTPD__BINDINGS__2__SECURITY__CACHE_CONTROL", "private") os.Setenv("SFTPGO_HTTPD__BINDINGS__2__EXTRA_CSS__0__PATH", "path1") os.Setenv("SFTPGO_HTTPD__BINDINGS__2__EXTRA_CSS__1__PATH", "path2") os.Setenv("SFTPGO_HTTPD__BINDINGS__2__BRANDING__WEB_ADMIN__FAVICON_PATH", "favicon.ico") @@ -1274,6 +1275,7 @@ func TestHTTPDBindingsFromEnv(t *testing.T) { os.Unsetenv("SFTPGO_HTTPD__BINDINGS__2__SECURITY__CONTENT_SECURITY_POLICY") os.Unsetenv("SFTPGO_HTTPD__BINDINGS__2__SECURITY__PERMISSIONS_POLICY") os.Unsetenv("SFTPGO_HTTPD__BINDINGS__2__SECURITY__CROSS_ORIGIN_OPENER_POLICY") + os.Unsetenv("SFTPGO_HTTPD__BINDINGS__2__SECURITY__CACHE_CONTROL") os.Unsetenv("SFTPGO_HTTPD__BINDINGS__2__EXTRA_CSS__0__PATH") os.Unsetenv("SFTPGO_HTTPD__BINDINGS__2__EXTRA_CSS__1__PATH") os.Unsetenv("SFTPGO_HTTPD__BINDINGS__2__BRANDING__WEB_ADMIN__FAVICON_PATH") @@ -1386,6 +1388,7 @@ func TestHTTPDBindingsFromEnv(t *testing.T) { require.Equal(t, "script-src $NONCE", bindings[2].Security.ContentSecurityPolicy) require.Equal(t, "fullscreen=(), geolocation=()", bindings[2].Security.PermissionsPolicy) require.Equal(t, "same-origin", bindings[2].Security.CrossOriginOpenerPolicy) + require.Equal(t, "private", bindings[2].Security.CacheControl) require.Equal(t, "favicon.ico", bindings[2].Branding.WebAdmin.FaviconPath) require.Equal(t, "logo.png", bindings[2].Branding.WebClient.LogoPath) require.Equal(t, "disclaimer", bindings[2].Branding.WebClient.DisclaimerName) diff --git a/internal/httpd/httpd.go b/internal/httpd/httpd.go index 6872dc69..4cac5f28 100644 --- a/internal/httpd/httpd.go +++ b/internal/httpd/httpd.go @@ -416,7 +416,9 @@ type SecurityConf struct { PermissionsPolicy string `json:"permissions_policy" mapstructure:"permissions_policy"` // CrossOriginOpenerPolicy allows to set the `Cross-Origin-Opener-Policy` header value. Default is "". CrossOriginOpenerPolicy string `json:"cross_origin_opener_policy" mapstructure:"cross_origin_opener_policy"` - proxyHeaders []string + // CacheControl allow to set the Cache-Control header value. + CacheControl string `json:"cache_control" mapstructure:"cache_control"` + proxyHeaders []string } func (s *SecurityConf) updateProxyHeaders() { diff --git a/internal/httpd/httpd_test.go b/internal/httpd/httpd_test.go index 704b7cd3..960e8f81 100644 --- a/internal/httpd/httpd_test.go +++ b/internal/httpd/httpd_test.go @@ -418,6 +418,7 @@ func TestMain(m *testing.M) { Value: "https", }, }, + CacheControl: "private", } httpdtest.SetBaseURL(httpBaseURL) // required to test sftpfs @@ -13149,12 +13150,14 @@ func TestDefender(t *testing.T) { req.RemoteAddr = remoteAddr rr = executeRequest(req) checkResponseCode(t, http.StatusOK, rr) + assert.Empty(t, rr.Header().Get("Cache-Control")) req, err = http.NewRequest(http.MethodGet, "/.well-known/acme-challenge/foo", nil) assert.NoError(t, err) req.RemoteAddr = remoteAddr rr = executeRequest(req) checkResponseCode(t, http.StatusNotFound, rr) + assert.Equal(t, "no-cache, no-store, max-age=0, must-revalidate, private", rr.Header().Get("Cache-Control")) _, err = httpdtest.RemoveUser(user, http.StatusOK) assert.NoError(t, err) diff --git a/internal/httpd/internal_test.go b/internal/httpd/internal_test.go index 5e4f5950..2c3d02e2 100644 --- a/internal/httpd/internal_test.go +++ b/internal/httpd/internal_test.go @@ -3181,6 +3181,7 @@ func TestSecureMiddlewareIntegration(t *testing.T) { STSIncludeSubdomains: true, STSPreload: true, ContentTypeNosniff: true, + CacheControl: "private", }, }, enableWebAdmin: true, @@ -3200,6 +3201,7 @@ func TestSecureMiddlewareIntegration(t *testing.T) { r.Host = "127.0.0.1" server.router.ServeHTTP(rr, r) assert.Equal(t, http.StatusForbidden, rr.Code) + assert.Equal(t, "no-cache, no-store, max-age=0, must-revalidate, private", rr.Header().Get("Cache-Control")) rr = httptest.NewRecorder() r.Header.Set(forwardedHostHeader, "www.sftpgo.com") diff --git a/internal/httpd/middleware.go b/internal/httpd/middleware.go index 12d69736..ac647f69 100644 --- a/internal/httpd/middleware.go +++ b/internal/httpd/middleware.go @@ -582,3 +582,17 @@ func checkPartialAuth(w http.ResponseWriter, r *http.Request, audience string, t } return nil } + +func cacheControlMiddleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Cache-Control", "no-cache, no-store, max-age=0, must-revalidate, private") + next.ServeHTTP(w, r) + }) +} + +func cleanCacheControlMiddleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Del("Cache-Control") + next.ServeHTTP(w, r) + }) +} diff --git a/internal/httpd/server.go b/internal/httpd/server.go index 839239c8..88020a34 100644 --- a/internal/httpd/server.go +++ b/internal/httpd/server.go @@ -1234,7 +1234,6 @@ func (s *httpdServer) initializeRouter() { s.router.Use(s.parseHeaders) s.router.Use(logger.NewStructuredLogger(logger.GetLogger())) s.router.Use(middleware.Recoverer) - s.router.Use(middleware.Maybe(s.checkConnection, s.mustCheckPath)) if s.binding.Security.Enabled { secureMiddleware := secure.New(secure.Options{ AllowedHosts: s.binding.Security.AllowedHosts, @@ -1250,6 +1249,9 @@ func (s *httpdServer) initializeRouter() { CrossOriginOpenerPolicy: s.binding.Security.CrossOriginOpenerPolicy, }) secureMiddleware.SetBadHostHandler(http.HandlerFunc(s.badHostHandler)) + if s.binding.Security.CacheControl == "private" { + s.router.Use(cacheControlMiddleware) + } s.router.Use(secureMiddleware.Handler) if s.binding.Security.HTTPSRedirect { s.router.Use(s.binding.Security.redirectHandler) @@ -1270,6 +1272,7 @@ func (s *httpdServer) initializeRouter() { }) s.router.Use(c.Handler) } + s.router.Use(middleware.Maybe(s.checkConnection, s.mustCheckPath)) s.router.Use(middleware.GetHead) s.router.Use(middleware.Maybe(middleware.StripSlashes, s.mustStripSlash)) @@ -1479,6 +1482,7 @@ func (s *httpdServer) initializeRouter() { if s.renderOpenAPI { s.router.Group(func(router chi.Router) { + router.Use(cleanCacheControlMiddleware) router.Use(compressor.Handler) serveStaticDir(router, webOpenAPIPath, s.openAPIPath, false) }) @@ -1487,6 +1491,7 @@ func (s *httpdServer) initializeRouter() { if s.enableWebAdmin || s.enableWebClient { s.router.Group(func(router chi.Router) { + router.Use(cleanCacheControlMiddleware) router.Use(compressor.Handler) serveStaticDir(router, webStaticFilesPath, s.staticFilesPath, true) }) @@ -1524,12 +1529,12 @@ func (s *httpdServer) setupWebClientRoutes() { r.Body = http.MaxBytesReader(w, r.Body, maxRequestSize) http.Redirect(w, r, webClientLoginPath, http.StatusFound) }) - s.router.Get(path.Join(webStaticFilesPath, "branding/webclient/logo.png"), + s.router.With(cleanCacheControlMiddleware).Get(path.Join(webStaticFilesPath, "branding/webclient/logo.png"), func(w http.ResponseWriter, r *http.Request) { r.Body = http.MaxBytesReader(w, r.Body, maxRequestSize) renderPNGImage(w, r, dbBrandingConfig.getWebClientLogo()) }) - s.router.Get(path.Join(webStaticFilesPath, "branding/webclient/favicon.png"), + s.router.With(cleanCacheControlMiddleware).Get(path.Join(webStaticFilesPath, "branding/webclient/favicon.png"), func(w http.ResponseWriter, r *http.Request) { r.Body = http.MaxBytesReader(w, r.Body, maxRequestSize) renderPNGImage(w, r, dbBrandingConfig.getWebClientFavicon()) @@ -1657,12 +1662,12 @@ func (s *httpdServer) setupWebAdminRoutes() { r.Body = http.MaxBytesReader(w, r.Body, maxLoginBodySize) s.redirectToWebPath(w, r, webAdminLoginPath) }) - s.router.Get(path.Join(webStaticFilesPath, "branding/webadmin/logo.png"), + s.router.With(cleanCacheControlMiddleware).Get(path.Join(webStaticFilesPath, "branding/webadmin/logo.png"), func(w http.ResponseWriter, r *http.Request) { r.Body = http.MaxBytesReader(w, r.Body, maxRequestSize) renderPNGImage(w, r, dbBrandingConfig.getWebAdminLogo()) }) - s.router.Get(path.Join(webStaticFilesPath, "branding/webadmin/favicon.png"), + s.router.With(cleanCacheControlMiddleware).Get(path.Join(webStaticFilesPath, "branding/webadmin/favicon.png"), func(w http.ResponseWriter, r *http.Request) { r.Body = http.MaxBytesReader(w, r.Body, maxRequestSize) renderPNGImage(w, r, dbBrandingConfig.getWebAdminFavicon()) diff --git a/sftpgo.json b/sftpgo.json index 74d2dbef..95998081 100644 --- a/sftpgo.json +++ b/sftpgo.json @@ -314,7 +314,8 @@ "content_type_nosniff": false, "content_security_policy": "", "permissions_policy": "", - "cross_origin_opener_policy": "" + "cross_origin_opener_policy": "", + "cache_control": "" }, "branding": { "web_admin": {