Skip to content
Merged
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
16 changes: 15 additions & 1 deletion app/builderconfig.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ package app
import (
"crypto/sha256"
"fmt"
"net/url"
"slices"

"github.com/attestantio/go-eth2-client/spec/gloas"
Expand Down Expand Up @@ -91,10 +92,23 @@ func initBuilderConfigMetrics(conf Config) {
}

for _, u := range conf.BuilderURLs {
builderURLGauge.WithLabelValues(u).Set(1)
builderURLGauge.WithLabelValues(withoutUserInfo(u)).Set(1)
}

builderMinBidGauge.WithLabelValues().Set(float64(conf.BuilderMinBid))
builderBoostFactorGauge.WithLabelValues().Set(float64(conf.BuilderBoostFactor))
builderMaxExecutionPaymentGauge.WithLabelValues().Set(float64(conf.BuilderMaxExecutionPayment))
}

// withoutUserInfo returns the URL without its user info. Relay URLs carry the relay's public key as the
// username, which only lengthens the label, and private builders may carry a password.
func withoutUserInfo(raw string) string {
u, err := url.Parse(raw)
if err != nil || u.User == nil {
return raw
}

u.User = nil

return u.String()
}
6 changes: 6 additions & 0 deletions app/builderconfig_internal_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -85,3 +85,9 @@ func TestBuilderHosts(t *testing.T) {
require.NoError(t, err)
require.Empty(t, hosts)
}

func TestWithoutUserInfo(t *testing.T) {
require.Equal(t, "https://relay.example.com", withoutUserInfo("https://0xabc@relay.example.com"))
require.Equal(t, "https://builder.example.com/path", withoutUserInfo("https://user:secret@builder.example.com/path"))
require.Equal(t, "http://mev-boost:18550", withoutUserInfo("http://mev-boost:18550"))
}
6 changes: 3 additions & 3 deletions app/eth1wrap/runner.go
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,7 @@ func NewDefaultEthClientRunner(addr string) EthClientRunner {
func(ctx context.Context, url string) (EthClient, error) {
cl, err := ethclient.DialContext(ctx, url)
if err != nil {
return nil, errors.Wrap(err, "connect to eth1 client")
return nil, errors.Wrap(log.RedactURLError(err), "connect to eth1 client")
}

return cl, nil
Expand Down Expand Up @@ -168,7 +168,7 @@ func (cl *client) VerifySmartContractBasedSignature(contractAddress string, hash
result, err := erc1271.IsValidSignature(nil, hash, sig)
if err != nil {
cl.maybeReconnect()
return false, err
return false, log.RedactURLError(err)
}

return result == erc1271MagicValue, nil
Expand Down Expand Up @@ -202,7 +202,7 @@ func (cl *client) ClientVersion(ctx context.Context) (string, error) {
var ver string
if err := eth1client.Client().CallContext(ctx, &ver, "web3_clientVersion"); err != nil {
cl.maybeReconnect()
return "", errors.Wrap(err, "get execution layer client version")
return "", errors.Wrap(log.RedactURLError(err), "get execution layer client version")
}

return ver, nil
Expand Down
2 changes: 1 addition & 1 deletion app/eth2wrap/eth2wrap.go
Original file line number Diff line number Diff line change
Expand Up @@ -361,7 +361,7 @@ func wrapError(ctx context.Context, err error, label string, fields ...z.Field)
}

err = errors.Wrap(uerr.Err, msg,
z.Str("url", uerr.URL),
z.Str("url", log.RedactURL(uerr.URL, false)),
z.Str("method", uerr.Op),
)
}
Expand Down
112 changes: 112 additions & 0 deletions app/log/redact.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
// Copyright © 2022-2026 Obol Labs Inc. Licensed under the terms of a Business Source License 1.1

package log

import (
"net/url"
"regexp"
"strings"

"github.com/obolnetwork/charon/app/errors"
)

const redacted = "xxxxx"

var (
// apiKeySegment matches path segments that look like API keys, e.g. "/v2/<key>" of hosted node providers:
// at least 20 characters, mixing letters and digits. Beacon API names like "prepare_beacon_proposer" have
// no digits, and hex values with a 0x prefix (roots, pubkeys, lock hashes) are skipped by the caller.
apiKeySegment = regexp.MustCompile(`^[A-Za-z0-9_-]{20,}$`)
digit = regexp.MustCompile(`[0-9]`)
letter = regexp.MustCompile(`[A-Za-z]`)
// publicQueryValue matches query values kept for debugging: integers like slots and indices, and 0x hex values.
publicQueryValue = regexp.MustCompile(`^([0-9]{1,20}|0x[0-9a-fA-F]*)$`)
)

// RedactURL returns raw with the URL's password and query values replaced with "xxxxx".
// Query values that are integers or 0x hex values, like slots and roots, are kept.
// If maskPath is true, a non-empty path is replaced too, since hosted beacon and execution
// providers put API keys in it. Otherwise only path segments that look like API keys are.
// Values that aren't absolute URLs are returned unchanged.
//
// Beacon node, execution and other endpoint URLs may contain credentials, so redact them
// wherever they are logged or used as metric labels.
func RedactURL(raw string, maskPath bool) string {

Check failure on line 34 in app/log/redact.go

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Refactor this method to reduce its Cognitive Complexity from 24 to the 15 allowed.

See more on https://sonarcloud.io/project/issues?id=ObolNetwork_charon&issues=AaGCOic5_DnVKKzXqDzj&open=AaGCOic5_DnVKKzXqDzj&pullRequest=4813
u, err := url.Parse(raw)
if err != nil || u.Scheme == "" || u.Host == "" {
return raw
}

changed := false

if _, ok := u.User.Password(); ok {
u.User = url.UserPassword(u.User.Username(), redacted)
changed = true
}

if u.RawQuery != "" {
q := u.Query()
queryChanged := false

for _, vals := range q {
for i, v := range vals {
if !publicQueryValue.MatchString(v) {
vals[i] = redacted
queryChanged = true
}
}
}

if queryChanged {
u.RawQuery = q.Encode()
changed = true
}
}

if maskPath && strings.Trim(u.Path, "/") != "" {
u.Path, u.RawPath = "/"+redacted, ""
changed = true
} else if !maskPath {
segments := strings.Split(u.Path, "/")
for i, s := range segments {
if looksLikeAPIKey(s) {
segments[i] = redacted
changed = true
}
}

u.Path, u.RawPath = strings.Join(segments, "/"), ""
}

if !changed {
return raw
}

return u.String()
}

// RedactURLs returns the URLs redacted with RedactURL.
func RedactURLs(raws []string, maskPath bool) []string {
resp := make([]string, 0, len(raws))
for _, raw := range raws {
resp = append(resp, RedactURL(raw, maskPath))
}

return resp
}

// RedactURLError redacts the URL of the first *url.Error in err's chain in place, keeping its path
// for debugging, and returns err. Go's HTTP client strips passwords from it, but not API keys
// in the path or query. Call it before wrapping err, since wrapping captures the message.
func RedactURLError(err error) error {
if uerr := new(url.Error); errors.As(err, &uerr) {
uerr.URL = RedactURL(uerr.URL, false)
}

return err
}

func looksLikeAPIKey(segment string) bool {
return !strings.HasPrefix(segment, "0x") && apiKeySegment.MatchString(segment) &&
digit.MatchString(segment) && letter.MatchString(segment)
}
69 changes: 69 additions & 0 deletions app/log/redact_internal_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
// Copyright © 2022-2026 Obol Labs Inc. Licensed under the terms of a Business Source License 1.1

package log

import (
"net/url"
"testing"

"github.com/stretchr/testify/require"

"github.com/obolnetwork/charon/app/errors"
)

func TestRedactURL(t *testing.T) {
tests := []struct {
in string
want string
}{
{"https://user:secret@bn.example.com:5052", "https://user:xxxxx@bn.example.com:5052"},
{"http://:secret@lighthouse:5052/eth/v1?x=1", "http://:xxxxx@lighthouse:5052/eth/v1?x=1"},
{"https://eth-mainnet.g.alchemy.com/v2/abcDEF123ghiJKL456mnoPQR789", "https://eth-mainnet.g.alchemy.com/v2/xxxxx"},
{"https://bn.example.com/eth/v1/beacon?apikey=secret&x=1", "https://bn.example.com/eth/v1/beacon?apikey=xxxxx&x=1"},
{"http://bn:5052/eth/v1/validator/attestation_data?slot=12345678&committee_index=3", "http://bn:5052/eth/v1/validator/attestation_data?slot=12345678&committee_index=3"},
{"http://bn:5052/eth/v3/validator/blocks/123?randao_reveal=0xab12&graffiti=hi", "http://bn:5052/eth/v3/validator/blocks/123?graffiti=xxxxx&randao_reveal=0xab12"},
{"http://bn:5052/eth/v1/validator/prepare_beacon_proposer", "http://bn:5052/eth/v1/validator/prepare_beacon_proposer"},
{"https://api.obol.tech/v1/lock/0x0f481bbd06a596cb3ba569b9de0cbfcf822b209c2d6877c98173df986dd3c0ec", "https://api.obol.tech/v1/lock/0x0f481bbd06a596cb3ba569b9de0cbfcf822b209c2d6877c98173df986dd3c0ec"},
{"https://user@bn.example.com", "https://user@bn.example.com"},
{"http://lighthouse:5052", "http://lighthouse:5052"},
{"user:secret@host", "user:secret@host"},
{"operator@example.com", "operator@example.com"},
}
for _, test := range tests {
t.Run(test.in, func(t *testing.T) {
require.Equal(t, test.want, RedactURL(test.in, false))
})
}
}

func TestRedactURLMaskPath(t *testing.T) {
tests := []struct {
in string
want string
}{
{"https://mainnet.infura.io/v3/0123456789abcdef", "https://mainnet.infura.io/xxxxx"},
{"https://user:secret@bn.example.com/key?token=t", "https://user:xxxxx@bn.example.com/xxxxx?token=xxxxx"},
{"http://lighthouse:5052", "http://lighthouse:5052"},
{"http://lighthouse:5052/", "http://lighthouse:5052/"},
{"lighthouse:5052", "lighthouse:5052"},
{"127.0.0.1:3600", "127.0.0.1:3600"},
}
for _, test := range tests {
t.Run(test.in, func(t *testing.T) {
require.Equal(t, test.want, RedactURL(test.in, true))
})
}

require.Equal(t, []string{"https://a.example.com/xxxxx", "http://lighthouse:5052"},
RedactURLs([]string{"https://a.example.com/v2/key", "http://lighthouse:5052"}, true))
}

func TestRedactURLError(t *testing.T) {
uerr := &url.Error{Op: "Post", URL: "https://el.example.com/v2/abcDEF123ghiJKL456mnoPQR789", Err: errors.New("timeout")}
err := errors.Wrap(RedactURLError(uerr), "call")

require.NotContains(t, err.Error(), "abcDEF123")
require.Contains(t, err.Error(), "https://el.example.com/v2/xxxxx")
require.ErrorIs(t, err, uerr)
require.NoError(t, RedactURLError(nil))
}
8 changes: 4 additions & 4 deletions app/sse/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ func newClient(addr string, header http.Header) (*client, error) {

u, err := url.Parse(prefixedAddr)
if err != nil {
return nil, errors.Wrap(err, "parse beacon node address", z.Str("addr", addr))
return nil, errors.Wrap(err, "parse beacon node address", z.Str("addr", log.RedactURL(addr, true)))
}

u.Path = "/eth/v1/events"
Expand Down Expand Up @@ -79,7 +79,7 @@ func newClientForT(addr, path string) (*client, error) {

u, err := url.Parse(prefixedAddr)
if err != nil {
return nil, errors.Wrap(err, "parse beacon node address", z.Str("addr", addr))
return nil, errors.Wrap(err, "parse beacon node address", z.Str("addr", log.RedactURL(addr, true)))
}

u.Path = path
Expand Down Expand Up @@ -115,7 +115,7 @@ func (c *client) start(ctx context.Context, eventFn EventHandler) error {
default:
// If error is not stream-related error, do not attempt retries and return the error.
if !errors.Is(err, errStreamConn) {
return errors.Wrap(err, "handle SSE payload", z.Str("url", c.sseURL.String()))
return errors.Wrap(err, "handle SSE payload", z.Str("url", log.RedactURL(c.sseURL.String(), false)))
}

if !backoffSet {
Expand All @@ -135,7 +135,7 @@ func (c *client) start(ctx context.Context, eventFn EventHandler) error {
}

func (c *client) connect(ctx context.Context, eventFn EventHandler) error {
log.Debug(ctx, "Connecting to SSE stream", z.Str("url", c.sseURL.String()))
log.Debug(ctx, "Connecting to SSE stream", z.Str("url", log.RedactURL(c.sseURL.String(), false)))

req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.sseURL.String(), nil)
if err != nil {
Expand Down
Loading
Loading