diff --git a/changelog/23534.txt b/changelog/23534.txt new file mode 100644 index 000000000000..5f101cb18a75 --- /dev/null +++ b/changelog/23534.txt @@ -0,0 +1,3 @@ +```release-note:feature +config/listener: allow per-listener configuration settings to redact sensitive parts of response to unauthenticated endpoints. +``` \ No newline at end of file diff --git a/command/server.go b/command/server.go index 7f8bd2d98114..cf5b18bbff1f 100644 --- a/command/server.go +++ b/command/server.go @@ -1530,7 +1530,8 @@ func (c *ServerCommand) Run(args []string) int { // mode if it's set core.SetClusterListenerAddrs(clusterAddrs) core.SetClusterHandler(vaulthttp.Handler.Handler(&vault.HandlerProperties{ - Core: core, + Core: core, + ListenerConfig: &configutil.Listener{}, })) // Attempt unsealing in a background goroutine. This is needed for when a @@ -2161,7 +2162,8 @@ func (c *ServerCommand) enableThreeNodeDevCluster(base *vault.CoreConfig, info m for _, core := range testCluster.Cores { core.Server.Handler = vaulthttp.Handler.Handler(&vault.HandlerProperties{ - Core: core.Core, + Core: core.Core, + ListenerConfig: &configutil.Listener{}, }) core.SetClusterHandler(core.Server.Handler) } diff --git a/command/server/config_test_helpers.go b/command/server/config_test_helpers.go index 4a09e8de2ed5..f7fe62ec9e95 100644 --- a/command/server/config_test_helpers.go +++ b/command/server/config_test_helpers.go @@ -886,6 +886,9 @@ listener "tcp" { enable_quit = true } chroot_namespace = "admin" + redact_addresses = true + redact_cluster_name = true + redact_version = true }`)) config := Config{ @@ -938,6 +941,9 @@ listener "tcp" { }, CustomResponseHeaders: DefaultCustomHeaders, ChrootNamespace: "admin/", + RedactAddresses: true, + RedactClusterName: true, + RedactVersion: true, }, }, }, diff --git a/http/handler.go b/http/handler.go index 735d2c9ffc44..ac8258eca89f 100644 --- a/http/handler.go +++ b/http/handler.go @@ -165,13 +165,18 @@ func handler(props *vault.HandlerProperties) http.Handler { mux.Handle("/v1/sys/host-info", handleLogicalNoForward(core)) mux.Handle("/v1/sys/init", handleSysInit(core)) - mux.Handle("/v1/sys/seal-status", handleSysSealStatus(core)) + mux.Handle("/v1/sys/seal-status", handleSysSealStatus(core, + WithRedactClusterName(props.ListenerConfig.RedactClusterName), + WithRedactVersion(props.ListenerConfig.RedactVersion))) mux.Handle("/v1/sys/seal-backend-status", handleSysSealBackendStatus(core)) mux.Handle("/v1/sys/seal", handleSysSeal(core)) mux.Handle("/v1/sys/step-down", handleRequestForwarding(core, handleSysStepDown(core))) mux.Handle("/v1/sys/unseal", handleSysUnseal(core)) - mux.Handle("/v1/sys/leader", handleSysLeader(core)) - mux.Handle("/v1/sys/health", handleSysHealth(core)) + mux.Handle("/v1/sys/leader", handleSysLeader(core, + WithRedactAddresses(props.ListenerConfig.RedactAddresses))) + mux.Handle("/v1/sys/health", handleSysHealth(core, + WithRedactClusterName(props.ListenerConfig.RedactClusterName), + WithRedactVersion(props.ListenerConfig.RedactVersion))) mux.Handle("/v1/sys/monitor", handleLogicalNoForward(core)) mux.Handle("/v1/sys/generate-root/attempt", handleRequestForwarding(core, handleAuditNonLogical(core, handleSysGenerateRootAttempt(core, vault.GenerateStandardRootTokenStrategy)))) diff --git a/http/handler_test.go b/http/handler_test.go index 039ffb08e274..a669672b60fb 100644 --- a/http/handler_test.go +++ b/http/handler_test.go @@ -17,6 +17,8 @@ import ( "strings" "testing" + "github.com/hashicorp/vault/internalshared/configutil" + "github.com/go-test/deep" "github.com/hashicorp/go-cleanhttp" "github.com/hashicorp/vault/helper/namespace" @@ -806,6 +808,7 @@ func testNonPrintable(t *testing.T, disable bool) { props := &vault.HandlerProperties{ Core: core, DisablePrintableCheck: disable, + ListenerConfig: &configutil.Listener{}, } TestServerWithListenerAndProperties(t, ln, addr, core, props) defer ln.Close() diff --git a/http/options.go b/http/options.go new file mode 100644 index 000000000000..b1200c018e72 --- /dev/null +++ b/http/options.go @@ -0,0 +1,71 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: BUSL-1.1 + +package http + +// ListenerConfigOption is how listenerConfigOptions are passed as arguments. +type ListenerConfigOption func(*listenerConfigOptions) error + +// listenerConfigOptions are used to represent configuration of listeners for http handlers. +type listenerConfigOptions struct { + withRedactionValue string + withRedactAddresses bool + withRedactClusterName bool + withRedactVersion bool +} + +// getDefaultOptions returns listenerConfigOptions with their default values. +func getDefaultOptions() listenerConfigOptions { + return listenerConfigOptions{ + withRedactionValue: "", // Redacted values will be set to an empty string by default. + } +} + +// getOpts applies each supplied ListenerConfigOption and returns the fully configured listenerConfigOptions. +// Each ListenerConfigOption is applied in the order it appears in the argument list, so it is +// possible to supply the same ListenerConfigOption numerous times and the 'last write wins'. +func getOpts(opt ...ListenerConfigOption) (listenerConfigOptions, error) { + opts := getDefaultOptions() + for _, o := range opt { + if o == nil { + continue + } + if err := o(&opts); err != nil { + return listenerConfigOptions{}, err + } + } + return opts, nil +} + +// WithRedactionValue provides an ListenerConfigOption to represent the value used to redact +// values which require redaction. +func WithRedactionValue(r string) ListenerConfigOption { + return func(o *listenerConfigOptions) error { + o.withRedactionValue = r + return nil + } +} + +// WithRedactAddresses provides an ListenerConfigOption to represent whether redaction of addresses is required. +func WithRedactAddresses(r bool) ListenerConfigOption { + return func(o *listenerConfigOptions) error { + o.withRedactAddresses = r + return nil + } +} + +// WithRedactClusterName provides an ListenerConfigOption to represent whether redaction of cluster names is required. +func WithRedactClusterName(r bool) ListenerConfigOption { + return func(o *listenerConfigOptions) error { + o.withRedactClusterName = r + return nil + } +} + +// WithRedactVersion provides an ListenerConfigOption to represent whether redaction of version is required. +func WithRedactVersion(r bool) ListenerConfigOption { + return func(o *listenerConfigOptions) error { + o.withRedactVersion = r + return nil + } +} diff --git a/http/options_test.go b/http/options_test.go new file mode 100644 index 000000000000..cee30c85c19e --- /dev/null +++ b/http/options_test.go @@ -0,0 +1,159 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: BUSL-1.1 + +package http + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +// TestOptions_Default ensures that the default values are as expected. +func TestOptions_Default(t *testing.T) { + opts := getDefaultOptions() + require.NotNil(t, opts) + require.Equal(t, "", opts.withRedactionValue) +} + +// TestOptions_WithRedactionValue ensures that we set the correct value to use for +// redaction when required. +func TestOptions_WithRedactionValue(t *testing.T) { + t.Parallel() + + tests := map[string]struct { + Value string + ExpectedValue string + IsErrorExpected bool + }{ + "empty": { + Value: "", + ExpectedValue: "", + IsErrorExpected: false, + }, + "whitespace": { + Value: " ", + ExpectedValue: " ", + IsErrorExpected: false, + }, + "value": { + Value: "*****", + ExpectedValue: "*****", + IsErrorExpected: false, + }, + } + + for name, tc := range tests { + name := name + tc := tc + t.Run(name, func(t *testing.T) { + t.Parallel() + opts := &listenerConfigOptions{} + applyOption := WithRedactionValue(tc.Value) + err := applyOption(opts) + switch { + case tc.IsErrorExpected: + require.Error(t, err) + default: + require.NoError(t, err) + require.Equal(t, tc.ExpectedValue, opts.withRedactionValue) + } + }) + } +} + +// TestOptions_WithRedactAddresses ensures that the option works as intended. +func TestOptions_WithRedactAddresses(t *testing.T) { + t.Parallel() + + tests := map[string]struct { + Value bool + ExpectedValue bool + }{ + "true": { + Value: true, + ExpectedValue: true, + }, + "false": { + Value: false, + ExpectedValue: false, + }, + } + + for name, tc := range tests { + name := name + tc := tc + t.Run(name, func(t *testing.T) { + t.Parallel() + opts := &listenerConfigOptions{} + applyOption := WithRedactAddresses(tc.Value) + err := applyOption(opts) + require.NoError(t, err) + require.Equal(t, tc.ExpectedValue, opts.withRedactAddresses) + }) + } +} + +// TestOptions_WithRedactClusterName ensures that the option works as intended. +func TestOptions_WithRedactClusterName(t *testing.T) { + t.Parallel() + + tests := map[string]struct { + Value bool + ExpectedValue bool + }{ + "true": { + Value: true, + ExpectedValue: true, + }, + "false": { + Value: false, + ExpectedValue: false, + }, + } + + for name, tc := range tests { + name := name + tc := tc + t.Run(name, func(t *testing.T) { + t.Parallel() + opts := &listenerConfigOptions{} + applyOption := WithRedactClusterName(tc.Value) + err := applyOption(opts) + require.NoError(t, err) + require.Equal(t, tc.ExpectedValue, opts.withRedactClusterName) + }) + } +} + +// TestOptions_WithRedactVersion ensures that the option works as intended. +func TestOptions_WithRedactVersion(t *testing.T) { + t.Parallel() + + tests := map[string]struct { + Value bool + ExpectedValue bool + }{ + "true": { + Value: true, + ExpectedValue: true, + }, + "false": { + Value: false, + ExpectedValue: false, + }, + } + + for name, tc := range tests { + name := name + tc := tc + t.Run(name, func(t *testing.T) { + t.Parallel() + opts := &listenerConfigOptions{} + applyOption := WithRedactVersion(tc.Value) + err := applyOption(opts) + require.NoError(t, err) + require.Equal(t, tc.ExpectedValue, opts.withRedactVersion) + }) + } +} diff --git a/http/sys_health.go b/http/sys_health.go index 65dbb051f43d..99edb95caff7 100644 --- a/http/sys_health.go +++ b/http/sys_health.go @@ -17,11 +17,11 @@ import ( "github.com/hashicorp/vault/version" ) -func handleSysHealth(core *vault.Core) http.Handler { +func handleSysHealth(core *vault.Core, opt ...ListenerConfigOption) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch r.Method { case "GET": - handleSysHealthGet(core, w, r) + handleSysHealthGet(core, w, r, opt...) case "HEAD": handleSysHealthHead(core, w, r) default: @@ -43,7 +43,7 @@ func fetchStatusCode(r *http.Request, field string) (int, bool, bool) { return statusCode, false, true } -func handleSysHealthGet(core *vault.Core, w http.ResponseWriter, r *http.Request) { +func handleSysHealthGet(core *vault.Core, w http.ResponseWriter, r *http.Request, opt ...ListenerConfigOption) { code, body, err := getSysHealth(core, r) if err != nil { core.Logger().Error("error checking health", "error", err) @@ -56,6 +56,16 @@ func handleSysHealthGet(core *vault.Core, w http.ResponseWriter, r *http.Request return } + opts, err := getOpts(opt...) + + if opts.withRedactVersion { + body.Version = opts.withRedactionValue + } + + if opts.withRedactClusterName { + body.ClusterName = opts.withRedactionValue + } + w.Header().Set("Content-Type", "application/json") w.WriteHeader(code) diff --git a/http/sys_leader.go b/http/sys_leader.go index 7a14164f4be7..c0c0cfd7d851 100644 --- a/http/sys_leader.go +++ b/http/sys_leader.go @@ -11,22 +11,29 @@ import ( // This endpoint is needed to answer queries before Vault unseals // or becomes the leader. -func handleSysLeader(core *vault.Core) http.Handler { +func handleSysLeader(core *vault.Core, opt ...ListenerConfigOption) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch r.Method { case "GET": - handleSysLeaderGet(core, w, r) + handleSysLeaderGet(core, w, opt...) default: respondError(w, http.StatusMethodNotAllowed, nil) } }) } -func handleSysLeaderGet(core *vault.Core, w http.ResponseWriter, r *http.Request) { +func handleSysLeaderGet(core *vault.Core, w http.ResponseWriter, opt ...ListenerConfigOption) { resp, err := core.GetLeaderStatus() if err != nil { respondError(w, http.StatusInternalServerError, err) return } + + opts, err := getOpts(opt...) + if opts.withRedactAddresses { + resp.LeaderAddress = opts.withRedactionValue + resp.LeaderClusterAddress = opts.withRedactionValue + } + respondOk(w, resp) } diff --git a/http/sys_seal.go b/http/sys_seal.go index 6dc69b844711..4448d9b2c0c7 100644 --- a/http/sys_seal.go +++ b/http/sys_seal.go @@ -98,7 +98,7 @@ func handleSysUnseal(core *vault.Core) http.Handler { return } core.ResetUnsealProcess() - handleSysSealStatusRaw(core, w, r) + handleSysSealStatusRaw(core, w) return } @@ -148,18 +148,18 @@ func handleSysUnseal(core *vault.Core) http.Handler { } // Return the seal status - handleSysSealStatusRaw(core, w, r) + handleSysSealStatusRaw(core, w) }) } -func handleSysSealStatus(core *vault.Core) http.Handler { +func handleSysSealStatus(core *vault.Core, opt ...ListenerConfigOption) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != "GET" { respondError(w, http.StatusMethodNotAllowed, nil) return } - handleSysSealStatusRaw(core, w, r) + handleSysSealStatusRaw(core, w, opt...) }) } @@ -174,7 +174,7 @@ func handleSysSealBackendStatus(core *vault.Core) http.Handler { }) } -func handleSysSealStatusRaw(core *vault.Core, w http.ResponseWriter, r *http.Request) { +func handleSysSealStatusRaw(core *vault.Core, w http.ResponseWriter, opt ...ListenerConfigOption) { ctx := context.Background() status, err := core.GetSealStatus(ctx) if err != nil { @@ -182,6 +182,17 @@ func handleSysSealStatusRaw(core *vault.Core, w http.ResponseWriter, r *http.Req return } + opts, err := getOpts(opt...) + + if opts.withRedactVersion { + status.Version = opts.withRedactionValue + status.BuildDate = opts.withRedactionValue + } + + if opts.withRedactClusterName { + status.ClusterName = opts.withRedactionValue + } + respondOk(w, status) } diff --git a/internalshared/configutil/listener.go b/internalshared/configutil/listener.go index 18507df767f9..20cc7bdfc52b 100644 --- a/internalshared/configutil/listener.go +++ b/internalshared/configutil/listener.go @@ -123,6 +123,14 @@ type Listener struct { // ChrootNamespace will prepend the specified namespace to requests ChrootNamespaceRaw interface{} `hcl:"chroot_namespace"` ChrootNamespace string `hcl:"-"` + + // Per-listener redaction configuration + RedactAddressesRaw any `hcl:"redact_addresses"` + RedactAddresses bool `hcl:"-"` + RedactClusterNameRaw any `hcl:"redact_cluster_name"` + RedactClusterName bool `hcl:"-"` + RedactVersionRaw any `hcl:"redact_version"` + RedactVersion bool `hcl:"-"` } // AgentAPI allows users to select which parts of the Agent API they want enabled. @@ -144,6 +152,32 @@ func (l *Listener) Validate(path string) []ConfigError { return append(results, ValidateUnusedFields(l.Profiling.UnusedKeys, path)...) } +// ParseSingleIPTemplate is used as a helper function to parse out a single IP +// address from a config parameter. +// If the input doesn't appear to contain the 'template' format, +// it will return the specified input unchanged. +func ParseSingleIPTemplate(ipTmpl string) (string, error) { + r := regexp.MustCompile("{{.*?}}") + if !r.MatchString(ipTmpl) { + return ipTmpl, nil + } + + out, err := template.Parse(ipTmpl) + if err != nil { + return "", fmt.Errorf("unable to parse address template %q: %v", ipTmpl, err) + } + + ips := strings.Split(out, " ") + switch len(ips) { + case 0: + return "", errors.New("no addresses found, please configure one") + case 1: + return strings.TrimSpace(ips[0]), nil + default: + return "", fmt.Errorf("multiple addresses found (%q), please configure one", out) + } +} + // ParseListeners attempts to parse the AST list of objects into listeners. func ParseListeners(list *ast.ObjectList) ([]*Listener, error) { listeners := make([]*Listener, len(list.Items)) @@ -209,6 +243,7 @@ func parseListener(item *ast.ObjectItem) (*Listener, error) { l.parseCORSSettings, l.parseHTTPHeaderSettings, l.parseChrootNamespaceSettings, + l.parseRedactionSettings, } { err := parser() if err != nil { @@ -565,28 +600,31 @@ func (l *Listener) parseCORSSettings() error { return nil } -// ParseSingleIPTemplate is used as a helper function to parse out a single IP -// address from a config parameter. -// If the input doesn't appear to contain the 'template' format, -// it will return the specified input unchanged. -func ParseSingleIPTemplate(ipTmpl string) (string, error) { - r := regexp.MustCompile("{{.*?}}") - if !r.MatchString(ipTmpl) { - return ipTmpl, nil - } +// parseRedactionSettings attempts to parse the raw listener redaction settings. +// The state of the listener will be modified, raw data will be cleared upon +// successful parsing. +func (l *Listener) parseRedactionSettings() error { + var err error - out, err := template.Parse(ipTmpl) - if err != nil { - return "", fmt.Errorf("unable to parse address template %q: %v", ipTmpl, err) + if l.RedactAddressesRaw != nil { + if l.RedactAddresses, err = parseutil.ParseBool(l.RedactAddressesRaw); err != nil { + return fmt.Errorf("invalid value for redact_addresses: %w", err) + } } - - ips := strings.Split(out, " ") - switch len(ips) { - case 0: - return "", errors.New("no addresses found, please configure one") - case 1: - return strings.TrimSpace(ips[0]), nil - default: - return "", fmt.Errorf("multiple addresses found (%q), please configure one", out) + if l.RedactClusterNameRaw != nil { + if l.RedactClusterName, err = parseutil.ParseBool(l.RedactClusterNameRaw); err != nil { + return fmt.Errorf("invalid value for redact_cluster_name: %w", err) + } + } + if l.RedactVersionRaw != nil { + if l.RedactVersion, err = parseutil.ParseBool(l.RedactVersionRaw); err != nil { + return fmt.Errorf("invalid value for redact_version: %w", err) + } } + + l.RedactAddressesRaw = nil + l.RedactClusterNameRaw = nil + l.RedactVersionRaw = nil + + return nil } diff --git a/internalshared/configutil/listener_test.go b/internalshared/configutil/listener_test.go index 13346495b420..ac4239efc1ff 100644 --- a/internalshared/configutil/listener_test.go +++ b/internalshared/configutil/listener_test.go @@ -972,3 +972,90 @@ func TestListener_parseChrootNamespaceSettings(t *testing.T) { }) } } + +// TestListener_parseRedactionSettings exercises the listener receiver parseRedactionSettings. +// We check various inputs to ensure we can parse the values as expected and +// assign the relevant value on the SharedConfig struct. +func TestListener_parseRedactionSettings(t *testing.T) { + tests := map[string]struct { + rawRedactAddresses any + expectedRedactAddresses bool + rawRedactClusterName any + expectedRedactClusterName bool + rawRedactVersion any + expectedRedactVersion bool + isErrorExpected bool + errorMessage string + }{ + "missing": { + isErrorExpected: false, + expectedRedactAddresses: false, + expectedRedactClusterName: false, + expectedRedactVersion: false, + }, + "redact-addresses-bad": { + rawRedactAddresses: "juan", + isErrorExpected: true, + errorMessage: "invalid value for redact_addresses", + }, + "redact-addresses-good": { + rawRedactAddresses: "true", + expectedRedactAddresses: true, + isErrorExpected: false, + }, + "redact-cluster-name-bad": { + rawRedactClusterName: "juan", + isErrorExpected: true, + errorMessage: "invalid value for redact_cluster_name", + }, + "redact-cluster-name-good": { + rawRedactClusterName: "true", + expectedRedactClusterName: true, + isErrorExpected: false, + }, + "redact-version-bad": { + rawRedactVersion: "juan", + isErrorExpected: true, + errorMessage: "invalid value for redact_version", + }, + "redact-version-good": { + rawRedactVersion: "true", + expectedRedactVersion: true, + isErrorExpected: false, + }, + } + + for name, tc := range tests { + name := name + tc := tc + t.Run(name, func(t *testing.T) { + t.Parallel() + + // Configure listener with raw values + l := &Listener{ + RedactAddressesRaw: tc.rawRedactAddresses, + RedactClusterNameRaw: tc.rawRedactClusterName, + RedactVersionRaw: tc.rawRedactVersion, + } + + err := l.parseRedactionSettings() + + switch { + case tc.isErrorExpected: + require.Error(t, err) + require.ErrorContains(t, err, tc.errorMessage) + default: + // Assert we got the relevant values. + require.NoError(t, err) + require.Equal(t, tc.expectedRedactAddresses, l.RedactAddresses) + require.Equal(t, tc.expectedRedactClusterName, l.RedactClusterName) + require.Equal(t, tc.expectedRedactVersion, l.RedactVersion) + + // Ensure the state was modified for the raw values. + require.Nil(t, l.RedactAddressesRaw) + require.Nil(t, l.RedactClusterNameRaw) + require.Nil(t, l.RedactVersionRaw) + } + }) + } +} diff --git a/vault/testing.go b/vault/testing.go index ba612eae50df..25c1bf71ed65 100644 --- a/vault/testing.go +++ b/vault/testing.go @@ -1279,6 +1279,13 @@ type certInfo struct { func NewTestCluster(t testing.T, base *CoreConfig, opts *TestClusterOptions) *TestCluster { var err error + if opts == nil { + opts = &TestClusterOptions{} + } + if opts.DefaultHandlerProperties.ListenerConfig == nil { + opts.DefaultHandlerProperties.ListenerConfig = &configutil.Listener{} + } + var numCores int if opts == nil || opts.NumCores == 0 { numCores = DefaultNumCores @@ -1296,7 +1303,7 @@ func NewTestCluster(t testing.T, base *CoreConfig, opts *TestClusterOptions) *Te testCluster.base = base switch { - case opts != nil && opts.Logger != nil: + case opts != nil && opts.Logger != nil && !reflect.ValueOf(opts.Logger).IsNil(): testCluster.Logger = opts.Logger default: testCluster.Logger = corehelpers.NewTestLogger(t) @@ -1310,7 +1317,7 @@ func NewTestCluster(t testing.T, base *CoreConfig, opts *TestClusterOptions) *Te } testCluster.TempDir = opts.TempDir } else { - tempDir, err := ioutil.TempDir("", "vault-test-cluster-") + tempDir, err := os.MkdirTemp("", "vault-test-cluster-") if err != nil { t.Fatal(err) }