diff --git a/api/cli/cli.go b/api/cli/cli.go index 7f6d80e68..0722c0b2e 100644 --- a/api/cli/cli.go +++ b/api/cli/cli.go @@ -62,6 +62,7 @@ func CLIFlags() *portainer.CLIFlags { KubectlShellImage: kingpin.Flag("kubectl-shell-image", "Kubectl shell image").Envar(portainer.KubectlShellImageEnvVar).Default(portainer.DefaultKubectlShellImage).String(), PullLimitCheckDisabled: kingpin.Flag("pull-limit-check-disabled", "Pull limit check").Envar(portainer.PullLimitCheckDisabledEnvVar).Default(defaultPullLimitCheckDisabled).Bool(), TrustedOrigins: kingpin.Flag("trusted-origins", "List of trusted origins for CSRF protection. Separate multiple origins with a comma.").Envar(portainer.TrustedOriginsEnvVar).String(), + CSP: kingpin.Flag("csp", "Content Security Policy (CSP) header").Envar(portainer.CSPEnvVar).Default("true").Bool(), } } diff --git a/api/cmd/portainer/main.go b/api/cmd/portainer/main.go index 23bd540a5..30493a3da 100644 --- a/api/cmd/portainer/main.go +++ b/api/cmd/portainer/main.go @@ -559,6 +559,7 @@ func buildServer(flags *portainer.CLIFlags) portainer.Server { Status: applicationStatus, BindAddress: *flags.Addr, BindAddressHTTPS: *flags.AddrHTTPS, + CSP: *flags.CSP, HTTPEnabled: sslDBSettings.HTTPEnabled, AssetsPath: *flags.Assets, DataStore: dataStore, diff --git a/api/http/handler/file/handler.go b/api/http/handler/file/handler.go index 66f81b64a..9e57478c8 100644 --- a/api/http/handler/file/handler.go +++ b/api/http/handler/file/handler.go @@ -17,12 +17,12 @@ type Handler struct { } // NewHandler creates a handler to serve static files. -func NewHandler(assetPublicPath string, wasInstanceDisabled func() bool) *Handler { +func NewHandler(assetPublicPath string, csp bool, wasInstanceDisabled func() bool) *Handler { h := &Handler{ Handler: security.MWSecureHeaders( gzhttp.GzipHandler(http.FileServer(http.Dir(assetPublicPath))), featureflags.IsEnabled("hsts"), - featureflags.IsEnabled("csp"), + csp, ), wasInstanceDisabled: wasInstanceDisabled, } @@ -36,6 +36,7 @@ func isHTML(acceptContent []string) bool { return true } } + return false } @@ -43,11 +44,13 @@ func (handler *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { if handler.wasInstanceDisabled() { if r.RequestURI == "/" || r.RequestURI == "/index.html" { http.Redirect(w, r, "/timeout.html", http.StatusTemporaryRedirect) + return } } else { if strings.HasPrefix(r.RequestURI, "/timeout.html") { http.Redirect(w, r, "/", http.StatusTemporaryRedirect) + return } } diff --git a/api/http/security/bouncer.go b/api/http/security/bouncer.go index eb240692d..fa7360f4c 100644 --- a/api/http/security/bouncer.go +++ b/api/http/security/bouncer.go @@ -35,6 +35,7 @@ type ( JWTAuthLookup(*http.Request) (*portainer.TokenData, error) TrustedEdgeEnvironmentAccess(dataservices.DataStoreTx, *portainer.Endpoint) error RevokeJWT(string) + DisableCSP() } // RequestBouncer represents an entity that manages API request accesses @@ -72,7 +73,7 @@ func NewRequestBouncer(dataStore dataservices.DataStore, jwtService portainer.JW jwtService: jwtService, apiKeyService: apiKeyService, hsts: featureflags.IsEnabled("hsts"), - csp: featureflags.IsEnabled("csp"), + csp: true, } go b.cleanUpExpiredJWT() @@ -80,6 +81,11 @@ func NewRequestBouncer(dataStore dataservices.DataStore, jwtService portainer.JW return b } +// DisableCSP disables Content Security Policy +func (bouncer *RequestBouncer) DisableCSP() { + bouncer.csp = false +} + // PublicAccess defines a security check for public API endpoints. // No authentication is required to access these endpoints. func (bouncer *RequestBouncer) PublicAccess(h http.Handler) http.Handler { @@ -528,7 +534,7 @@ func MWSecureHeaders(next http.Handler, hsts, csp bool) http.Handler { } if csp { - w.Header().Set("Content-Security-Policy", "script-src 'self' cdn.matomo.cloud") + w.Header().Set("Content-Security-Policy", "script-src 'self' cdn.matomo.cloud; frame-ancestors 'none';") } w.Header().Set("X-Content-Type-Options", "nosniff") diff --git a/api/http/security/bouncer_test.go b/api/http/security/bouncer_test.go index 4d84dcfee..3dd42fdc5 100644 --- a/api/http/security/bouncer_test.go +++ b/api/http/security/bouncer_test.go @@ -530,3 +530,34 @@ func TestJWTRevocation(t *testing.T) { require.Equal(t, 1, revokeLen()) } + +func TestCSPHeaderDefault(t *testing.T) { + b := NewRequestBouncer(nil, nil, nil) + + srv := httptest.NewServer( + b.PublicAccess(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {})), + ) + defer srv.Close() + + resp, err := http.Get(srv.URL + "/") + require.NoError(t, err) + defer resp.Body.Close() + + require.Contains(t, resp.Header, "Content-Security-Policy") +} + +func TestCSPHeaderDisabled(t *testing.T) { + b := NewRequestBouncer(nil, nil, nil) + b.DisableCSP() + + srv := httptest.NewServer( + b.PublicAccess(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {})), + ) + defer srv.Close() + + resp, err := http.Get(srv.URL + "/") + require.NoError(t, err) + defer resp.Body.Close() + + require.NotContains(t, resp.Header, "Content-Security-Policy") +} diff --git a/api/http/server.go b/api/http/server.go index e876ca5d2..5bfa36379 100644 --- a/api/http/server.go +++ b/api/http/server.go @@ -77,6 +77,7 @@ type Server struct { AuthorizationService *authorization.Service BindAddress string BindAddressHTTPS string + CSP bool HTTPEnabled bool AssetsPath string Status *portainer.Status @@ -121,6 +122,9 @@ func (server *Server) Start() error { kubernetesTokenCacheManager := server.KubernetesTokenCacheManager requestBouncer := security.NewRequestBouncer(server.DataStore, server.JWTService, server.APIKeyService) + if !server.CSP { + requestBouncer.DisableCSP() + } rateLimiter := security.NewRateLimiter(10, 1*time.Second, 1*time.Hour) offlineGate := offlinegate.NewOfflineGate() @@ -200,7 +204,7 @@ func (server *Server) Start() error { var dockerHandler = dockerhandler.NewHandler(requestBouncer, server.AuthorizationService, server.DataStore, server.DockerClientFactory, containerService) - var fileHandler = file.NewHandler(filepath.Join(server.AssetsPath, "public"), adminMonitor.WasInstanceDisabled) + var fileHandler = file.NewHandler(filepath.Join(server.AssetsPath, "public"), server.CSP, adminMonitor.WasInstanceDisabled) var endpointHelmHandler = helm.NewHandler(requestBouncer, server.DataStore, server.JWTService, server.KubernetesDeployer, server.HelmPackageManager, server.KubeClusterAccessService) diff --git a/api/internal/testhelpers/request_bouncer.go b/api/internal/testhelpers/request_bouncer.go index b89154549..0586dffef 100644 --- a/api/internal/testhelpers/request_bouncer.go +++ b/api/internal/testhelpers/request_bouncer.go @@ -60,6 +60,8 @@ func (testRequestBouncer) JWTAuthLookup(r *http.Request) (*portainer.TokenData, func (testRequestBouncer) RevokeJWT(jti string) {} +func (testRequestBouncer) DisableCSP() {} + // AddTestSecurityCookie adds a security cookie to the request func AddTestSecurityCookie(r *http.Request, jwt string) { r.AddCookie(&http.Cookie{ diff --git a/api/portainer.go b/api/portainer.go index bdd52aa34..da15479ee 100644 --- a/api/portainer.go +++ b/api/portainer.go @@ -110,6 +110,7 @@ type ( AdminPassword *string AdminPasswordFile *string Assets *string + CSP *bool Data *string FeatureFlags *[]string EnableEdgeComputeFeatures *bool @@ -1791,6 +1792,8 @@ const ( LicenseCheckInURL = LicenseServerBaseURL + "/licenses/checkin" // TrustedOriginsEnvVar is the environment variable used to set the trusted origins for CSRF protection TrustedOriginsEnvVar = "TRUSTED_ORIGINS" + // CSPEnvVar is the environment variable used to enable/disable the Content Security Policy + CSPEnvVar = "CSP" ) // List of supported features