1
0
Fork 0
mirror of https://github.com/portainer/portainer.git synced 2025-07-19 13:29:41 +02:00

feat(csp): enable CSP by default BE-11961 (#872)

This commit is contained in:
andres-portainer 2025-07-09 16:15:43 -03:00 committed by GitHub
parent 4d11aa8655
commit ea4b334c7e
8 changed files with 56 additions and 5 deletions

View file

@ -62,6 +62,7 @@ func CLIFlags() *portainer.CLIFlags {
KubectlShellImage: kingpin.Flag("kubectl-shell-image", "Kubectl shell image").Envar(portainer.KubectlShellImageEnvVar).Default(portainer.DefaultKubectlShellImage).String(), 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(), 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(), 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(),
} }
} }

View file

@ -559,6 +559,7 @@ func buildServer(flags *portainer.CLIFlags) portainer.Server {
Status: applicationStatus, Status: applicationStatus,
BindAddress: *flags.Addr, BindAddress: *flags.Addr,
BindAddressHTTPS: *flags.AddrHTTPS, BindAddressHTTPS: *flags.AddrHTTPS,
CSP: *flags.CSP,
HTTPEnabled: sslDBSettings.HTTPEnabled, HTTPEnabled: sslDBSettings.HTTPEnabled,
AssetsPath: *flags.Assets, AssetsPath: *flags.Assets,
DataStore: dataStore, DataStore: dataStore,

View file

@ -17,12 +17,12 @@ type Handler struct {
} }
// NewHandler creates a handler to serve static files. // 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{ h := &Handler{
Handler: security.MWSecureHeaders( Handler: security.MWSecureHeaders(
gzhttp.GzipHandler(http.FileServer(http.Dir(assetPublicPath))), gzhttp.GzipHandler(http.FileServer(http.Dir(assetPublicPath))),
featureflags.IsEnabled("hsts"), featureflags.IsEnabled("hsts"),
featureflags.IsEnabled("csp"), csp,
), ),
wasInstanceDisabled: wasInstanceDisabled, wasInstanceDisabled: wasInstanceDisabled,
} }
@ -36,6 +36,7 @@ func isHTML(acceptContent []string) bool {
return true return true
} }
} }
return false return false
} }
@ -43,11 +44,13 @@ func (handler *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
if handler.wasInstanceDisabled() { if handler.wasInstanceDisabled() {
if r.RequestURI == "/" || r.RequestURI == "/index.html" { if r.RequestURI == "/" || r.RequestURI == "/index.html" {
http.Redirect(w, r, "/timeout.html", http.StatusTemporaryRedirect) http.Redirect(w, r, "/timeout.html", http.StatusTemporaryRedirect)
return return
} }
} else { } else {
if strings.HasPrefix(r.RequestURI, "/timeout.html") { if strings.HasPrefix(r.RequestURI, "/timeout.html") {
http.Redirect(w, r, "/", http.StatusTemporaryRedirect) http.Redirect(w, r, "/", http.StatusTemporaryRedirect)
return return
} }
} }

View file

@ -35,6 +35,7 @@ type (
JWTAuthLookup(*http.Request) (*portainer.TokenData, error) JWTAuthLookup(*http.Request) (*portainer.TokenData, error)
TrustedEdgeEnvironmentAccess(dataservices.DataStoreTx, *portainer.Endpoint) error TrustedEdgeEnvironmentAccess(dataservices.DataStoreTx, *portainer.Endpoint) error
RevokeJWT(string) RevokeJWT(string)
DisableCSP()
} }
// RequestBouncer represents an entity that manages API request accesses // RequestBouncer represents an entity that manages API request accesses
@ -72,7 +73,7 @@ func NewRequestBouncer(dataStore dataservices.DataStore, jwtService portainer.JW
jwtService: jwtService, jwtService: jwtService,
apiKeyService: apiKeyService, apiKeyService: apiKeyService,
hsts: featureflags.IsEnabled("hsts"), hsts: featureflags.IsEnabled("hsts"),
csp: featureflags.IsEnabled("csp"), csp: true,
} }
go b.cleanUpExpiredJWT() go b.cleanUpExpiredJWT()
@ -80,6 +81,11 @@ func NewRequestBouncer(dataStore dataservices.DataStore, jwtService portainer.JW
return b return b
} }
// DisableCSP disables Content Security Policy
func (bouncer *RequestBouncer) DisableCSP() {
bouncer.csp = false
}
// PublicAccess defines a security check for public API endpoints. // PublicAccess defines a security check for public API endpoints.
// No authentication is required to access these endpoints. // No authentication is required to access these endpoints.
func (bouncer *RequestBouncer) PublicAccess(h http.Handler) http.Handler { 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 { 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") w.Header().Set("X-Content-Type-Options", "nosniff")

View file

@ -530,3 +530,34 @@ func TestJWTRevocation(t *testing.T) {
require.Equal(t, 1, revokeLen()) 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")
}

View file

@ -77,6 +77,7 @@ type Server struct {
AuthorizationService *authorization.Service AuthorizationService *authorization.Service
BindAddress string BindAddress string
BindAddressHTTPS string BindAddressHTTPS string
CSP bool
HTTPEnabled bool HTTPEnabled bool
AssetsPath string AssetsPath string
Status *portainer.Status Status *portainer.Status
@ -121,6 +122,9 @@ func (server *Server) Start() error {
kubernetesTokenCacheManager := server.KubernetesTokenCacheManager kubernetesTokenCacheManager := server.KubernetesTokenCacheManager
requestBouncer := security.NewRequestBouncer(server.DataStore, server.JWTService, server.APIKeyService) requestBouncer := security.NewRequestBouncer(server.DataStore, server.JWTService, server.APIKeyService)
if !server.CSP {
requestBouncer.DisableCSP()
}
rateLimiter := security.NewRateLimiter(10, 1*time.Second, 1*time.Hour) rateLimiter := security.NewRateLimiter(10, 1*time.Second, 1*time.Hour)
offlineGate := offlinegate.NewOfflineGate() 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 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) var endpointHelmHandler = helm.NewHandler(requestBouncer, server.DataStore, server.JWTService, server.KubernetesDeployer, server.HelmPackageManager, server.KubeClusterAccessService)

View file

@ -60,6 +60,8 @@ func (testRequestBouncer) JWTAuthLookup(r *http.Request) (*portainer.TokenData,
func (testRequestBouncer) RevokeJWT(jti string) {} func (testRequestBouncer) RevokeJWT(jti string) {}
func (testRequestBouncer) DisableCSP() {}
// AddTestSecurityCookie adds a security cookie to the request // AddTestSecurityCookie adds a security cookie to the request
func AddTestSecurityCookie(r *http.Request, jwt string) { func AddTestSecurityCookie(r *http.Request, jwt string) {
r.AddCookie(&http.Cookie{ r.AddCookie(&http.Cookie{

View file

@ -110,6 +110,7 @@ type (
AdminPassword *string AdminPassword *string
AdminPasswordFile *string AdminPasswordFile *string
Assets *string Assets *string
CSP *bool
Data *string Data *string
FeatureFlags *[]string FeatureFlags *[]string
EnableEdgeComputeFeatures *bool EnableEdgeComputeFeatures *bool
@ -1791,6 +1792,8 @@ const (
LicenseCheckInURL = LicenseServerBaseURL + "/licenses/checkin" LicenseCheckInURL = LicenseServerBaseURL + "/licenses/checkin"
// TrustedOriginsEnvVar is the environment variable used to set the trusted origins for CSRF protection // TrustedOriginsEnvVar is the environment variable used to set the trusted origins for CSRF protection
TrustedOriginsEnvVar = "TRUSTED_ORIGINS" TrustedOriginsEnvVar = "TRUSTED_ORIGINS"
// CSPEnvVar is the environment variable used to enable/disable the Content Security Policy
CSPEnvVar = "CSP"
) )
// List of supported features // List of supported features