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