diff --git a/.changeset/healthy-wasps-hang.md b/.changeset/healthy-wasps-hang.md new file mode 100644 index 00000000000..2057bddb5ee --- /dev/null +++ b/.changeset/healthy-wasps-hang.md @@ -0,0 +1,5 @@ +--- +"chainlink": minor +--- + +Send sanitized config to chip ingress #updated diff --git a/core/cmd/shell_local.go b/core/cmd/shell_local.go index b002d7d3da7..96ad658f0fa 100644 --- a/core/cmd/shell_local.go +++ b/core/cmd/shell_local.go @@ -346,8 +346,16 @@ func (s *Shell) EmitNodeConfig(ctx context.Context) { // Get the effective TOML configuration (with defaults applied) _, effectiveTOML := s.Config.ConfigTOML() - // Emit the configuration as a message - err := emitter.Emit(ctx, effectiveTOML) + // Remove credentials and query/fragment secrets from URL values before + // emitting the configuration to beholder. + sanitizedTOML, err := beholderServices.SanitizeConfigTOML(effectiveTOML) + if err != nil { + s.Logger.Errorf("failed to sanitize node configuration for beholder, not emitting: %v", err) + return + } + + // Emit the sanitized configuration as a message + err = emitter.Emit(ctx, sanitizedTOML) if err != nil { s.Logger.Errorf("failed to emit node configuration through beholder: %v", err) } else { diff --git a/core/cmd/shell_test.go b/core/cmd/shell_test.go index 649da07841c..61f26c31ec4 100644 --- a/core/cmd/shell_test.go +++ b/core/cmd/shell_test.go @@ -589,7 +589,8 @@ func TestShell_emitNodeConfig(t *testing.T) { lggr := logger.TestLogger(t) gcfg := configtest.NewGeneralConfig(t, func(c *chainlink.Config, s *chainlink.Secrets) { - // use defaults + c.EVM[0].Nodes[0].WSURL = commoncfg.MustParseURL("wss://user:pass@rpc.example.com/ws?key=secret") + c.EVM[0].Nodes[0].HTTPURL = commoncfg.MustParseURL("https://user:pass@rpc.example.com?key=secret") }) shell := &cmd.Shell{ @@ -618,6 +619,12 @@ func TestShell_emitNodeConfig(t *testing.T) { require.Contains(t, baseMsg.Msg, "[Database]", "Configuration should contain Database section") require.Contains(t, baseMsg.Msg, "[WebServer]", "Configuration should contain WebServer section") + // Verify credentials and query secrets are not leaked + require.NotContains(t, baseMsg.Msg, "user:pass", "Credential user:pass should not leak in emitted config") + require.NotContains(t, baseMsg.Msg, "key=secret", "Query secret should not leak in emitted config") + require.Contains(t, baseMsg.Msg, "userxxx:passwordxxx", "Credentials should be redacted to userxxx:passwordxxx") + require.Contains(t, baseMsg.Msg, "?", "Query should be replaced with placeholder") + // Verify labels are set correctly require.Equal(t, "Application", baseMsg.Labels["system"]) require.Equal(t, static.Version, baseMsg.Labels["version"]) diff --git a/core/services/beholder/config_sanitize.go b/core/services/beholder/config_sanitize.go new file mode 100644 index 00000000000..8143214f1be --- /dev/null +++ b/core/services/beholder/config_sanitize.go @@ -0,0 +1,111 @@ +package beholder + +import ( + "fmt" + "net/url" + + gotoml "github.com/pelletier/go-toml/v2" +) + +const ( + redactedUsername = "userxxx" + redactedPassword = "passwordxxx" + queryPlaceholder = "" + fragmentPlaceholder = "" +) + +// SanitizeConfigTOML removes credentials and query/fragment secrets from URL +// values in a TOML config string. It is used to sanitize the node configuration +// before it is emitted through the Beholder message emitter. +// +// For every string value that parses as a URL with a non-empty scheme and host, +// the following transformations are applied: +// - usernames are replaced with "userxxx" (if present) +// - passwords are replaced with "passwordxxx" (if present) +// - query strings are replaced with "?" (if present) +// - fragments are replaced with "#" (if present) +// - paths, ports, and schemes are left untouched +// +// Clean URLs (without userinfo, query, or fragment) and non-URL strings are +// returned unchanged. The TOML is decoded to a generic map, sanitized, and +// re-encoded, so keys may be reordered alphabetically. The resulting TOML is +// semantically identical to the input except for the sanitized URL values. +func SanitizeConfigTOML(in string) (string, error) { + var m map[string]any + if err := gotoml.Unmarshal([]byte(in), &m); err != nil { + return "", fmt.Errorf("decode config TOML: %w", err) + } + sanitizeValue(m) + out, err := gotoml.Marshal(m) + if err != nil { + return "", fmt.Errorf("encode config TOML: %w", err) + } + return string(out), nil +} + +func sanitizeValue(v any) any { + switch val := v.(type) { + case map[string]any: + for k, sub := range val { + val[k] = sanitizeValue(sub) + } + return val + case []any: + for i, sub := range val { + val[i] = sanitizeValue(sub) + } + return val + case string: + return sanitizeURLString(val) + default: + return v + } +} + +func sanitizeURLString(s string) string { + u, err := url.Parse(s) + if err != nil || u.Scheme == "" || u.Host == "" { + return s + } + + changed := false + hasFragment := u.Fragment != "" || u.RawFragment != "" + + if u.User != nil { + username := u.User.Username() + _, hasPass := u.User.Password() + switch { + case username != "" && hasPass: + u.User = url.UserPassword(redactedUsername, redactedPassword) + case username != "": + u.User = url.User(redactedUsername) + case hasPass: + u.User = url.UserPassword("", redactedPassword) + default: + u.User = url.User("") + } + changed = true + } + + if u.RawQuery != "" || u.ForceQuery { + u.RawQuery = queryPlaceholder + u.ForceQuery = false + changed = true + } + + if hasFragment { + u.Fragment = "" + u.RawFragment = "" + changed = true + } + + if !changed { + return s + } + + out := u.String() + if hasFragment { + out += "#" + fragmentPlaceholder + } + return out +} diff --git a/core/services/beholder/config_sanitize_test.go b/core/services/beholder/config_sanitize_test.go new file mode 100644 index 00000000000..cb8c6ff063e --- /dev/null +++ b/core/services/beholder/config_sanitize_test.go @@ -0,0 +1,207 @@ +package beholder + +import ( + "testing" + + gotoml "github.com/pelletier/go-toml/v2" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestSanitizeURLString(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + in string + expected string + }{ + { + name: "url with userinfo query and fragment", + in: "wss://user:pass@rpc.example.com/path?key=k#frag", + expected: "wss://userxxx:passwordxxx@rpc.example.com/path?#", + }, + { + name: "url with userinfo only", + in: "wss://user:pass@rpc.example.com/path", + expected: "wss://userxxx:passwordxxx@rpc.example.com/path", + }, + { + name: "url with username only", + in: "wss://user@rpc.example.com/path", + expected: "wss://userxxx@rpc.example.com/path", + }, + { + name: "url with password only", + in: "wss://:pass@rpc.example.com/path", + expected: "wss://:passwordxxx@rpc.example.com/path", + }, + { + name: "url with empty password", + in: "wss://user:@rpc.example.com/path", + expected: "wss://userxxx:passwordxxx@rpc.example.com/path", + }, + { + name: "url with query only", + in: "https://rpc.example.com/v1?key=k", + expected: "https://rpc.example.com/v1?", + }, + { + name: "url with fragment only", + in: "https://rpc.example.com/v1#section", + expected: "https://rpc.example.com/v1#", + }, + { + name: "url with query and fragment no userinfo", + in: "https://rpc.example.com/v1?key=k#section", + expected: "https://rpc.example.com/v1?#", + }, + { + name: "clean url unchanged", + in: "https://rpc.example.com/v1", + expected: "https://rpc.example.com/v1", + }, + { + name: "url with port unchanged", + in: "wss://user:pass@rpc.example.com:8546/path", + expected: "wss://userxxx:passwordxxx@rpc.example.com:8546/path", + }, + { + name: "non-url string unchanged", + in: "prom.test", + expected: "prom.test", + }, + { + name: "string with host:port but no scheme unchanged", + in: "localhost:50051", + expected: "localhost:50051", + }, + { + name: "string with hash but no scheme-host unchanged", + in: "section#2", + expected: "section#2", + }, + { + name: "string with question but no scheme-host unchanged", + in: "color?blue", + expected: "color?blue", + }, + { + name: "mailto url without host unchanged", + in: "mailto:user@example.com", + expected: "mailto:user@example.com", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + assert.Equal(t, tt.expected, sanitizeURLString(tt.in)) + }) + } +} + +func TestSanitizeConfigTOML(t *testing.T) { + t.Parallel() + + t.Run("valid toml with urls", func(t *testing.T) { + t.Parallel() + in := ` +URL = 'wss://user:pass@rpc.example.com/path?key=k#frag' +QueryOnly = 'https://rpc.example.com/v1?key=k' +Clean = 'https://rpc.example.com/v1' +Header = 'Authorization: token' +Duration = '10s' +` + + out, err := SanitizeConfigTOML(in) + require.NoError(t, err) + + var m map[string]any + require.NoError(t, gotoml.Unmarshal([]byte(out), &m)) + + assert.Equal(t, "wss://userxxx:passwordxxx@rpc.example.com/path?#", m["URL"]) + assert.Equal(t, "https://rpc.example.com/v1?", m["QueryOnly"]) + assert.Equal(t, "https://rpc.example.com/v1", m["Clean"]) + assert.Equal(t, "Authorization: token", m["Header"]) + assert.Equal(t, "10s", m["Duration"]) + }) + + t.Run("urls in array", func(t *testing.T) { + t.Parallel() + in := `URLs = ['https://u:p@h1/q?k1=v1', 'https://h2/q']` + + out, err := SanitizeConfigTOML(in) + require.NoError(t, err) + + var m map[string]any + require.NoError(t, gotoml.Unmarshal([]byte(out), &m)) + + urls, ok := m["URLs"].([]any) + require.True(t, ok) + require.Len(t, urls, 2) + assert.Equal(t, "https://userxxx:passwordxxx@h1/q?", urls[0]) + assert.Equal(t, "https://h2/q", urls[1]) + }) + + t.Run("nested table urls", func(t *testing.T) { + t.Parallel() + in := ` +[Node] +URL = 'https://user:pass@rpc.example.com' +Name = 'primary' +` + + out, err := SanitizeConfigTOML(in) + require.NoError(t, err) + + var m map[string]any + require.NoError(t, gotoml.Unmarshal([]byte(out), &m)) + + node, ok := m["Node"].(map[string]any) + require.True(t, ok) + assert.Equal(t, "https://userxxx:passwordxxx@rpc.example.com", node["URL"]) + assert.Equal(t, "primary", node["Name"]) + }) + + t.Run("array of tables urls", func(t *testing.T) { + t.Parallel() + in := ` +[[Nodes]] +URL = 'https://user:pass@rpc.example.com' + +[[Nodes]] +URL = 'https://rpc.example.com?key=k' +` + + out, err := SanitizeConfigTOML(in) + require.NoError(t, err) + + var m map[string]any + require.NoError(t, gotoml.Unmarshal([]byte(out), &m)) + + nodes, ok := m["Nodes"].([]any) + require.True(t, ok) + require.Len(t, nodes, 2) + + node0 := nodes[0].(map[string]any) + assert.Equal(t, "https://userxxx:passwordxxx@rpc.example.com", node0["URL"]) + + node1 := nodes[1].(map[string]any) + assert.Equal(t, "https://rpc.example.com?", node1["URL"]) + }) + + t.Run("empty input", func(t *testing.T) { + t.Parallel() + out, err := SanitizeConfigTOML("") + require.NoError(t, err) + assert.Empty(t, out) + }) + + t.Run("invalid toml returns error", func(t *testing.T) { + t.Parallel() + _, err := SanitizeConfigTOML("[unclosed") + require.Error(t, err) + assert.Contains(t, err.Error(), "decode config TOML") + }) +}