Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/healthy-wasps-hang.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"chainlink": minor
---

Send sanitized config to chip ingress #updated
12 changes: 10 additions & 2 deletions core/cmd/shell_local.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
9 changes: 8 additions & 1 deletion core/cmd/shell_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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{
Expand Down Expand Up @@ -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>", "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"])
Expand Down
111 changes: 111 additions & 0 deletions core/services/beholder/config_sanitize.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
package beholder

import (
"fmt"
"net/url"

gotoml "github.com/pelletier/go-toml/v2"
)

const (
redactedUsername = "userxxx"
redactedPassword = "passwordxxx"
queryPlaceholder = "<QUERY>"
fragmentPlaceholder = "<FRAGMENT>"
)

// 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 "?<QUERY>" (if present)
// - fragments are replaced with "#<FRAGMENT>" (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
}
207 changes: 207 additions & 0 deletions core/services/beholder/config_sanitize_test.go
Original file line number Diff line number Diff line change
@@ -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?<QUERY>#<FRAGMENT>",
},
{
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?<QUERY>",
},
{
name: "url with fragment only",
in: "https://rpc.example.com/v1#section",
expected: "https://rpc.example.com/v1#<FRAGMENT>",
},
{
name: "url with query and fragment no userinfo",
in: "https://rpc.example.com/v1?key=k#section",
expected: "https://rpc.example.com/v1?<QUERY>#<FRAGMENT>",
},
{
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?<QUERY>#<FRAGMENT>", m["URL"])
assert.Equal(t, "https://rpc.example.com/v1?<QUERY>", 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?<QUERY>", 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?<QUERY>", 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")
})
}
Loading