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:
parent
4d11aa8655
commit
ea4b334c7e
8 changed files with 56 additions and 5 deletions
|
@ -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(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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")
|
||||||
|
|
|
@ -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")
|
||||||
|
}
|
||||||
|
|
|
@ -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)
|
||||||
|
|
||||||
|
|
|
@ -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{
|
||||||
|
|
|
@ -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
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue