diff --git a/.changelog/18085.txt b/.changelog/18085.txt new file mode 100644 index 000000000..2cb1d9d2b --- /dev/null +++ b/.changelog/18085.txt @@ -0,0 +1,3 @@ +```release-note:improvement +ui: Added configurable content security policy header +``` diff --git a/command/agent/http.go b/command/agent/http.go index 459749691..aeeb5666e 100644 --- a/command/agent/http.go +++ b/command/agent/http.go @@ -37,6 +37,7 @@ import ( "github.com/hashicorp/nomad/helper/tlsutil" "github.com/hashicorp/nomad/nomad" "github.com/hashicorp/nomad/nomad/structs" + "github.com/hashicorp/nomad/nomad/structs/config" ) const ( @@ -505,7 +506,7 @@ func (s *HTTPServer) registerHandlers(enableDebug bool) { uiConfigEnabled := agentConfig.UI != nil && agentConfig.UI.Enabled if uiEnabled && uiConfigEnabled { - s.mux.Handle("/ui/", http.StripPrefix("/ui/", s.handleUI(http.FileServer(&UIAssetWrapper{FileSystem: assetFS()})))) + s.mux.Handle("/ui/", http.StripPrefix("/ui/", s.handleUI(agentConfig.UI.ContentSecurityPolicy, http.FileServer(&UIAssetWrapper{FileSystem: assetFS()})))) s.logger.Debug("UI is enabled") } else { // Write the stubHTML @@ -646,10 +647,10 @@ func (e *codedError) Code() int { return e.code } -func (s *HTTPServer) handleUI(h http.Handler) http.Handler { +func (s *HTTPServer) handleUI(policy *config.ContentSecurityPolicy, h http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { header := w.Header() - header.Add("Content-Security-Policy", "default-src 'none'; connect-src *; img-src 'self' data:; script-src 'self'; style-src 'self' 'unsafe-inline'; form-action 'none'; frame-ancestors 'none'") + header.Add("Content-Security-Policy", policy.String()) h.ServeHTTP(w, req) }) } diff --git a/nomad/structs/config/ui.go b/nomad/structs/config/ui.go index 0fd97a1b8..f195520be 100644 --- a/nomad/structs/config/ui.go +++ b/nomad/structs/config/ui.go @@ -3,6 +3,13 @@ package config +import ( + "fmt" + "strings" + + "golang.org/x/exp/slices" +) + // UIConfig contains the operator configuration of the web UI // Note: // before extending this configuration, consider reviewing NMD-125 @@ -11,6 +18,9 @@ type UIConfig struct { // Enabled is used to enable the web UI Enabled bool `hcl:"enabled"` + // ContentSecurityPolicy is used to configure the CSP header + ContentSecurityPolicy *ContentSecurityPolicy `hcl:"content_security_policy"` + // Consul configures deep links for Consul UI Consul *ConsulUIConfig `hcl:"consul"` @@ -21,6 +31,87 @@ type UIConfig struct { Label *LabelUIConfig `hcl:"label"` } +// only covers the elements of +// https://developer.mozilla.org/en-US/docs/Web/HTTP/CSP we need or care about +type ContentSecurityPolicy struct { + ConnectSrc []string `hcl:"connect_src"` + DefaultSrc []string `hcl:"default_src"` + FormAction []string `hcl:"form_action"` + FrameAncestors []string `hcl:"frame_ancestors"` + ImgSrc []string `hcl:"img_src"` + ScriptSrc []string `hcl:"script_src"` + StyleSrc []string `hcl:"style_src"` +} + +// Copy returns a copy of this Vault UI config. +func (old *ContentSecurityPolicy) Copy() *ContentSecurityPolicy { + if old == nil { + return nil + } + + nc := new(ContentSecurityPolicy) + *nc = *old + nc.ConnectSrc = slices.Clone(old.ConnectSrc) + nc.DefaultSrc = slices.Clone(old.DefaultSrc) + nc.FormAction = slices.Clone(old.FormAction) + nc.FrameAncestors = slices.Clone(old.FrameAncestors) + nc.ImgSrc = slices.Clone(old.ImgSrc) + nc.ScriptSrc = slices.Clone(old.ScriptSrc) + nc.StyleSrc = slices.Clone(old.StyleSrc) + return nc +} + +func (csp *ContentSecurityPolicy) String() string { + return fmt.Sprintf("default-src %s; connect-src %s; img-src %s; script-src %s; style-src %s; form-action %s; frame-ancestors %s", strings.Join(csp.DefaultSrc, " "), strings.Join(csp.ConnectSrc, " "), strings.Join(csp.ImgSrc, " "), strings.Join(csp.ScriptSrc, " "), strings.Join(csp.StyleSrc, " "), strings.Join(csp.FormAction, " "), strings.Join(csp.FrameAncestors, " ")) +} + +func (csp *ContentSecurityPolicy) Merge(other *ContentSecurityPolicy) *ContentSecurityPolicy { + result := csp.Copy() + if result == nil { + result = &ContentSecurityPolicy{} + } + if other == nil { + return result + } + + if len(other.ConnectSrc) > 0 { + result.ConnectSrc = other.ConnectSrc + } + if len(other.DefaultSrc) > 0 { + result.DefaultSrc = other.DefaultSrc + } + if len(other.FormAction) > 0 { + result.FormAction = other.FormAction + } + if len(other.FrameAncestors) > 0 { + result.FrameAncestors = other.FrameAncestors + } + if len(other.ImgSrc) > 0 { + result.ImgSrc = other.ImgSrc + } + if len(other.ScriptSrc) > 0 { + result.ScriptSrc = other.ScriptSrc + } + if len(other.StyleSrc) > 0 { + result.StyleSrc = other.StyleSrc + } + + return result + +} + +func DefaultCSPConfig() *ContentSecurityPolicy { + return &ContentSecurityPolicy{ + ConnectSrc: []string{"*"}, + DefaultSrc: []string{"'none'"}, + FormAction: []string{"'none'"}, + FrameAncestors: []string{"'none'"}, + ImgSrc: []string{"'self'", "data:"}, + ScriptSrc: []string{"'self'"}, + StyleSrc: []string{"'self'", "'unsafe-inline'"}, + } +} + // ConsulUIConfig configures deep links to this cluster's Consul type ConsulUIConfig struct { @@ -47,10 +138,11 @@ type LabelUIConfig struct { // `ui` configuration. func DefaultUIConfig() *UIConfig { return &UIConfig{ - Enabled: true, - Consul: &ConsulUIConfig{}, - Vault: &VaultUIConfig{}, - Label: &LabelUIConfig{}, + Enabled: true, + Consul: &ConsulUIConfig{}, + Vault: &VaultUIConfig{}, + Label: &LabelUIConfig{}, + ContentSecurityPolicy: DefaultCSPConfig(), } } @@ -84,6 +176,7 @@ func (old *UIConfig) Merge(other *UIConfig) *UIConfig { result.Consul = result.Consul.Merge(other.Consul) result.Vault = result.Vault.Merge(other.Vault) result.Label = result.Label.Merge(other.Label) + result.ContentSecurityPolicy = result.ContentSecurityPolicy.Merge(other.ContentSecurityPolicy) return result } diff --git a/nomad/structs/config/ui_test.go b/nomad/structs/config/ui_test.go index 2e5a7a205..5ad57805e 100644 --- a/nomad/structs/config/ui_test.go +++ b/nomad/structs/config/ui_test.go @@ -26,6 +26,7 @@ func TestUIConfig_Merge(t *testing.T) { BackgroundColor: "blue", TextColor: "#fff", }, + ContentSecurityPolicy: DefaultCSPConfig(), } testCases := []struct { @@ -64,6 +65,7 @@ func TestUIConfig_Merge(t *testing.T) { Consul: &ConsulUIConfig{ BaseUIURL: "http://consul-other.example.com:8500", }, + ContentSecurityPolicy: DefaultCSPConfig(), }, right: &UIConfig{}, expect: &UIConfig{ @@ -71,8 +73,9 @@ func TestUIConfig_Merge(t *testing.T) { Consul: &ConsulUIConfig{ BaseUIURL: "http://consul-other.example.com:8500", }, - Vault: &VaultUIConfig{}, - Label: &LabelUIConfig{}, + Vault: &VaultUIConfig{}, + Label: &LabelUIConfig{}, + ContentSecurityPolicy: DefaultCSPConfig(), }, }, } diff --git a/website/content/docs/configuration/ui.mdx b/website/content/docs/configuration/ui.mdx index 312e9e979..a51c91258 100644 --- a/website/content/docs/configuration/ui.mdx +++ b/website/content/docs/configuration/ui.mdx @@ -16,6 +16,16 @@ The `ui` block configures the Nomad agent's [web UI]. ui { enabled = true + content_security_policy { + connect_src = ["*"] + default_src = ["'none'"] + form_action = ["'none'"] + frame_ancestors = ["'none'"] + img_src = ["'self'","data:"] + script_src = ["'self'"] + style_src = ["'self'","'unsafe-inline'"] + } + consul { ui_url = "https://consul.example.com:8501/ui" } @@ -48,6 +58,27 @@ and the configuration is individual to each agent. - `label` ([Label]: nil) - Configures a user-defined label to display in the Nomad Web UI header. +## `content_security_policy` Parameters + +The `content_security_policy` block configures the HTTP +[Content-Security-Policy](https://developer.mozilla.org/en-US/docs/Web/HTTP/CSP) +header sent with the web UI response. + +- `connect_src` `(array: ["*"])` - Specifies the valid sources for + `connect-src` in the Content Security Policy header. +- `default_src` `(array: ["'none'"])` - Specifies the valid sources for + `default-src` in the Content Security Policy header. +- `form_action` `(array: ["'none'"])` - Specifies the valid sources for + `form-action` in the Content Security Policy header. +- `frame_ancestors` `(array: ["'none'"])` - Specifies the valid sources + for `frame-ancestors` in the Content Security Policy header. +- `img_src` `(array: ["'self'", "data:"])` - Specifies the valid sources + for `img-src` in the Content Security Policy header. +- `script_src` `(array: ["'self'"])` - Specifies the valid sources for + `script-src` in the Content Security Policy header. +- `style_src` `(array: ["'self'","'unsafe-inline'"])` - Specifies the + valid sources for `style-src` in the Content Security Policy header. + ## `consul` Parameters - `ui_url` `(string: "")` - Specifies the full base URL to a Consul