From cd69506a90967ada4ab82f343b16ebcb7a11197f Mon Sep 17 00:00:00 2001 From: snowkide Date: Tue, 4 Aug 2026 14:34:47 +0200 Subject: [PATCH 1/5] feat(auth): forwardedClientId strategy and WS subscription event metrics Add AuthTypeForwardedClientId so a trusted gateway-injected X-Client-Id (Envoy apiKeyAuth.forwardClientIDHeader) becomes User.Id for per-client Prometheus labels. Emit erpc_ws_subscription_events_total (and dropped) on client notification write/overflow for third-party RPC observability. Co-authored-by: Cursor --- auth/authorizer.go | 5 +++ auth/http.go | 15 ++++++++ auth/payload.go | 17 ++++++--- auth/strategy_forwarded_client_id.go | 40 ++++++++++++++++++++ auth/strategy_forwarded_client_id_test.go | 45 ++++++++++++++++++++++ common/config.go | 35 +++++++++++------ common/defaults.go | 17 +++++++++ common/validation.go | 15 ++++++++ erpc/subscription_manager.go | 6 ++- indexer/adapters/wsclient/adapter.go | 46 ++++++++++++++++++++++- telemetry/metrics.go | 15 ++++++++ typescript/config/src/generated.ts | 12 ++++++ typescript/config/src/index.ts | 1 + 13 files changed, 250 insertions(+), 19 deletions(-) create mode 100644 auth/strategy_forwarded_client_id.go create mode 100644 auth/strategy_forwarded_client_id_test.go diff --git a/auth/authorizer.go b/auth/authorizer.go index b37713278..19b29d50f 100644 --- a/auth/authorizer.go +++ b/auth/authorizer.go @@ -63,6 +63,11 @@ func NewAuthorizer(appCtx context.Context, logger *zerolog.Logger, projectId str if err != nil { return nil, err } + case common.AuthTypeForwardedClientId: + if cfg.ForwardedClientId == nil { + return nil, common.NewErrInvalidConfig("forwardedClientId strategy config is nil") + } + strategy = NewForwardedClientIdStrategy(cfg.ForwardedClientId) default: return nil, common.NewErrInvalidConfig(fmt.Sprintf("unknown auth strategy type: %s", cfg.Type)) } diff --git a/auth/http.go b/auth/http.go index 3b6698662..2dab88323 100644 --- a/auth/http.go +++ b/auth/http.go @@ -77,6 +77,12 @@ func NewPayloadFromHttp(method string, remoteAddr string, headers http.Header, a Message: normalizeSiweMessage(msg), } } + } else if clientId := firstNonEmptyHeader(headers, "X-Client-Id", "x-client-id"); clientId != "" { + // Gateway-injected identity after edge API-key auth (Envoy forwardClientIDHeader). + ap.Type = common.AuthTypeForwardedClientId + ap.ForwardedClientId = &ForwardedClientIdPayload{ + Value: clientId, + } } // Default to network strategy when no other auth signals are present. @@ -87,6 +93,15 @@ func NewPayloadFromHttp(method string, remoteAddr string, headers http.Header, a return ap, nil } +func firstNonEmptyHeader(headers http.Header, names ...string) string { + for _, name := range names { + if v := strings.TrimSpace(headers.Get(name)); v != "" { + return v + } + } + return "" +} + func normalizeSiweMessage(msg string) string { decoded, err := base64.StdEncoding.DecodeString(msg) if err != nil { diff --git a/auth/payload.go b/auth/payload.go index 712003534..9971ba7c5 100644 --- a/auth/payload.go +++ b/auth/payload.go @@ -3,11 +3,18 @@ package auth import "github.com/erpc/erpc/common" type AuthPayload struct { - Method string - Type common.AuthType - Secret *SecretPayload - Jwt *JwtPayload - Siwe *SiwePayload + Method string + Type common.AuthType + Secret *SecretPayload + Jwt *JwtPayload + Siwe *SiwePayload + ForwardedClientId *ForwardedClientIdPayload +} + +// ForwardedClientIdPayload carries a gateway-injected client id (not a secret). +type ForwardedClientIdPayload struct { + Value string + RateLimitBudget string } // This payload is used by both "secret" and "database" strategies diff --git a/auth/strategy_forwarded_client_id.go b/auth/strategy_forwarded_client_id.go new file mode 100644 index 000000000..af14caeea --- /dev/null +++ b/auth/strategy_forwarded_client_id.go @@ -0,0 +1,40 @@ +package auth + +import ( + "context" + "strings" + + "github.com/erpc/erpc/common" +) + +// ForwardedClientIdStrategy authenticates using a non-secret client id +// header injected by a trusted gateway after API-key verification +// (e.g. Envoy SecurityPolicy apiKeyAuth.forwardClientIDHeader). +type ForwardedClientIdStrategy struct { + cfg *common.ForwardedClientIdStrategyConfig +} + +var _ AuthStrategy = &ForwardedClientIdStrategy{} + +func NewForwardedClientIdStrategy(cfg *common.ForwardedClientIdStrategyConfig) *ForwardedClientIdStrategy { + return &ForwardedClientIdStrategy{cfg: cfg} +} + +func (s *ForwardedClientIdStrategy) Supports(ap *AuthPayload) bool { + return ap != nil && ap.Type == common.AuthTypeForwardedClientId +} + +func (s *ForwardedClientIdStrategy) Authenticate(ctx context.Context, req *common.NormalizedRequest, ap *AuthPayload) (*common.User, error) { + if ap == nil || ap.ForwardedClientId == nil || strings.TrimSpace(ap.ForwardedClientId.Value) == "" { + return nil, common.NewErrAuthUnauthorized("forwardedClientId", "missing client id header") + } + + id := strings.TrimSpace(ap.ForwardedClientId.Value) + user := &common.User{Id: id} + if s.cfg != nil && s.cfg.RateLimitBudget != "" { + user.RateLimitBudget = s.cfg.RateLimitBudget + } else if ap.ForwardedClientId.RateLimitBudget != "" { + user.RateLimitBudget = ap.ForwardedClientId.RateLimitBudget + } + return user, nil +} diff --git a/auth/strategy_forwarded_client_id_test.go b/auth/strategy_forwarded_client_id_test.go new file mode 100644 index 000000000..1f026467f --- /dev/null +++ b/auth/strategy_forwarded_client_id_test.go @@ -0,0 +1,45 @@ +package auth + +import ( + "context" + "net/http" + "net/url" + "testing" + + "github.com/erpc/erpc/common" + "github.com/stretchr/testify/require" +) + +func TestNewPayloadFromHttp_ForwardedClientId(t *testing.T) { + headers := http.Header{} + headers.Set("X-Client-Id", "cl-no-alpha") + ap, err := NewPayloadFromHttp("eth_blockNumber", "1.2.3.4:1234", headers, url.Values{}) + require.NoError(t, err) + require.Equal(t, common.AuthTypeForwardedClientId, ap.Type) + require.NotNil(t, ap.ForwardedClientId) + require.Equal(t, "cl-no-alpha", ap.ForwardedClientId.Value) +} + +func TestForwardedClientIdStrategy_Authenticate(t *testing.T) { + s := NewForwardedClientIdStrategy(&common.ForwardedClientIdStrategyConfig{ + Header: "X-Client-Id", + RateLimitBudget: "default-budget", + }) + ap := &AuthPayload{ + Type: common.AuthTypeForwardedClientId, + ForwardedClientId: &ForwardedClientIdPayload{ + Value: "cl-no-beta", + }, + } + user, err := s.Authenticate(context.Background(), nil, ap) + require.NoError(t, err) + require.Equal(t, "cl-no-beta", user.Id) + require.Equal(t, "default-budget", user.RateLimitBudget) +} + +func TestForwardedClientIdStrategy_MissingHeader(t *testing.T) { + s := NewForwardedClientIdStrategy(&common.ForwardedClientIdStrategyConfig{}) + ap := &AuthPayload{Type: common.AuthTypeForwardedClientId} + _, err := s.Authenticate(context.Background(), nil, ap) + require.Error(t, err) +} diff --git a/common/config.go b/common/config.go index f392e488d..f8b45d728 100644 --- a/common/config.go +++ b/common/config.go @@ -2389,11 +2389,12 @@ func (s *SelectionPolicyConfig) UnmarshalYAML(unmarshal func(interface{}) error) type AuthType string const ( - AuthTypeSecret AuthType = "secret" - AuthTypeDatabase AuthType = "database" - AuthTypeJwt AuthType = "jwt" - AuthTypeSiwe AuthType = "siwe" - AuthTypeNetwork AuthType = "network" + AuthTypeSecret AuthType = "secret" + AuthTypeDatabase AuthType = "database" + AuthTypeJwt AuthType = "jwt" + AuthTypeSiwe AuthType = "siwe" + AuthTypeNetwork AuthType = "network" + AuthTypeForwardedClientId AuthType = "forwardedClientId" ) type AuthConfig struct { @@ -2405,12 +2406,24 @@ type AuthStrategyConfig struct { AllowMethods []string `yaml:"allowMethods,omitempty" json:"allowMethods,omitempty"` RateLimitBudget string `yaml:"rateLimitBudget,omitempty" json:"rateLimitBudget,omitempty"` - Type AuthType `yaml:"type" json:"type" tstype:"TsAuthType"` - Network *NetworkStrategyConfig `yaml:"network,omitempty" json:"network,omitempty"` - Secret *SecretStrategyConfig `yaml:"secret,omitempty" json:"secret,omitempty"` - Database *DatabaseStrategyConfig `yaml:"database,omitempty" json:"database,omitempty"` - Jwt *JwtStrategyConfig `yaml:"jwt,omitempty" json:"jwt,omitempty"` - Siwe *SiweStrategyConfig `yaml:"siwe,omitempty" json:"siwe,omitempty"` + Type AuthType `yaml:"type" json:"type" tstype:"TsAuthType"` + Network *NetworkStrategyConfig `yaml:"network,omitempty" json:"network,omitempty"` + Secret *SecretStrategyConfig `yaml:"secret,omitempty" json:"secret,omitempty"` + Database *DatabaseStrategyConfig `yaml:"database,omitempty" json:"database,omitempty"` + Jwt *JwtStrategyConfig `yaml:"jwt,omitempty" json:"jwt,omitempty"` + Siwe *SiweStrategyConfig `yaml:"siwe,omitempty" json:"siwe,omitempty"` + ForwardedClientId *ForwardedClientIdStrategyConfig `yaml:"forwardedClientId,omitempty" json:"forwardedClientId,omitempty"` +} + +// ForwardedClientIdStrategyConfig trusts a non-secret client identity header +// injected by an upstream gateway after API-key auth (e.g. Envoy +// apiKeyAuth.forwardClientIDHeader → X-Client-Id). Must only be enabled +// behind a gateway that strips client-supplied values of that header. +type ForwardedClientIdStrategyConfig struct { + // Header is the request header carrying the client id. Default: "X-Client-Id". + Header string `yaml:"header,omitempty" json:"header,omitempty"` + // RateLimitBudget, if set, is applied to the authenticated user. + RateLimitBudget string `yaml:"rateLimitBudget,omitempty" json:"rateLimitBudget,omitempty"` } type SecretStrategyConfig struct { diff --git a/common/defaults.go b/common/defaults.go index 19fcecc18..045da8026 100644 --- a/common/defaults.go +++ b/common/defaults.go @@ -2681,6 +2681,23 @@ func (s *AuthStrategyConfig) SetDefaults() error { } } + if s.Type == AuthTypeForwardedClientId && s.ForwardedClientId == nil { + s.ForwardedClientId = &ForwardedClientIdStrategyConfig{} + } + if s.ForwardedClientId != nil { + s.Type = AuthTypeForwardedClientId + if err := s.ForwardedClientId.SetDefaults(); err != nil { + return fmt.Errorf("failed to set defaults for forwardedClientId strategy: %w", err) + } + } + + return nil +} + +func (s *ForwardedClientIdStrategyConfig) SetDefaults() error { + if s.Header == "" { + s.Header = "X-Client-Id" + } return nil } diff --git a/common/validation.go b/common/validation.go index 2143b03ec..b464e7730 100644 --- a/common/validation.go +++ b/common/validation.go @@ -729,6 +729,13 @@ func (s *AuthStrategyConfig) Validate() error { if err := s.Database.Validate(); err != nil { return err } + case AuthTypeForwardedClientId: + if s.ForwardedClientId == nil { + return fmt.Errorf("auth.*.forwardedClientId is required for forwardedClientId strategy") + } + if err := s.ForwardedClientId.Validate(); err != nil { + return err + } default: return fmt.Errorf("auth.*.type '%s' is invalid must be one of: %v", s.Type, []AuthType{ AuthTypeNetwork, @@ -736,11 +743,19 @@ func (s *AuthStrategyConfig) Validate() error { AuthTypeJwt, AuthTypeSiwe, AuthTypeDatabase, + AuthTypeForwardedClientId, }) } return nil } +func (s *ForwardedClientIdStrategyConfig) Validate() error { + if s == nil { + return fmt.Errorf("auth.*.forwardedClientId is required") + } + return nil +} + func (s *DatabaseStrategyConfig) Validate() error { if s.Connector == nil { return fmt.Errorf("auth.*.database.connector is required") diff --git a/erpc/subscription_manager.go b/erpc/subscription_manager.go index 2c05546ca..c16d92c82 100644 --- a/erpc/subscription_manager.go +++ b/erpc/subscription_manager.go @@ -174,7 +174,11 @@ func (sm *SubscriptionManager) Subscribe( return nil, err } - conn.adapter.AddSubscription(clientSubID, networkId, kind, filterHash) + conn.adapter.AddSubscription(clientSubID, networkId, kind, filterHash, wsclient.SubscriptionLabels{ + Project: project.Config.Id, + User: nq.UserId(), + AgentName: nq.AgentName(), + }) sm.bySubID.Store(clientSubID, &subRecord{ clientSubID: clientSubID, connID: wsc.id, diff --git a/indexer/adapters/wsclient/adapter.go b/indexer/adapters/wsclient/adapter.go index 3841404e9..8a037358d 100644 --- a/indexer/adapters/wsclient/adapter.go +++ b/indexer/adapters/wsclient/adapter.go @@ -12,6 +12,7 @@ import ( "sync/atomic" "github.com/erpc/erpc/indexer" + "github.com/erpc/erpc/telemetry" "github.com/rs/zerolog" ) @@ -61,12 +62,21 @@ type routeKey struct { filterHash string } +// SubscriptionLabels are frozen at eth_subscribe time for per-client metrics +// on delivered / dropped notification events. +type SubscriptionLabels struct { + Project string + User string + AgentName string +} + type clientSub struct { id string kind indexer.EventKind networkID string // filterHash is "" for newHeads. filterHash string + labels SubscriptionLabels notify chan json.RawMessage done chan struct{} @@ -129,13 +139,23 @@ func (a *Adapter) Deliver(ev indexer.IndexedEvent) { // AddSubscription registers a client subscription on this connection and // starts its writer goroutine. clientSubId is the erpc-generated opaque // ID the caller already returned to the client. filterHash is "" for -// newHeads. -func (a *Adapter) AddSubscription(clientSubID, networkID string, kind indexer.EventKind, filterHash string) { +// newHeads. labels are used for Prometheus counters on deliver/drop. +func (a *Adapter) AddSubscription(clientSubID, networkID string, kind indexer.EventKind, filterHash string, labels SubscriptionLabels) { + if labels.Project == "" { + labels.Project = "n/a" + } + if labels.User == "" { + labels.User = "n/a" + } + if labels.AgentName == "" { + labels.AgentName = "unknown" + } sub := &clientSub{ id: clientSubID, kind: kind, networkID: networkID, filterHash: filterHash, + labels: labels, notify: make(chan json.RawMessage, clientNotifyBufferSize), done: make(chan struct{}), } @@ -246,7 +266,15 @@ func (a *Adapter) runWriter(sub *clientSub) { Msg("failed to write subscription notification") // Errors are per-sub; the connection-close path will // Drain us when the peer is truly gone. + continue } + telemetry.MetricWsSubscriptionEventsTotal.WithLabelValues( + sub.labels.Project, + sub.networkID, + sub.kind.String(), + sub.labels.User, + sub.labels.AgentName, + ).Inc() } } } @@ -262,8 +290,22 @@ func enqueue(sub *clientSub, payload json.RawMessage) { // Buffer full; drop oldest to make room. select { case <-sub.notify: + telemetry.MetricWsSubscriptionEventsDroppedTotal.WithLabelValues( + sub.labels.Project, + sub.networkID, + sub.kind.String(), + sub.labels.User, + sub.labels.AgentName, + ).Inc() default: // Concurrent drain won the race — drop this message. + telemetry.MetricWsSubscriptionEventsDroppedTotal.WithLabelValues( + sub.labels.Project, + sub.networkID, + sub.kind.String(), + sub.labels.User, + sub.labels.AgentName, + ).Inc() return } } diff --git a/telemetry/metrics.go b/telemetry/metrics.go index e7e05f56e..8517d6869 100644 --- a/telemetry/metrics.go +++ b/telemetry/metrics.go @@ -88,6 +88,21 @@ var ( Help: "Whether the upstream WebSocket connection is currently established (1) or down/wedged (0).", }, []string{"project", "vendor", "network", "upstream"}) + // Client-facing WebSocket subscription push events (newHeads / logs / + // pending txs) successfully written to a downstream client. Maps to + // third-party RPC observability rpc_ws_event_count_total. + MetricWsSubscriptionEventsTotal = promauto.NewCounterVec(prometheus.CounterOpts{ + Namespace: "erpc", + Name: "ws_subscription_events_total", + Help: "Subscription notifications successfully written to a client WebSocket.", + }, []string{"project", "network", "kind", "user", "agent_name"}) + + MetricWsSubscriptionEventsDroppedTotal = promauto.NewCounterVec(prometheus.CounterOpts{ + Namespace: "erpc", + Name: "ws_subscription_events_dropped_total", + Help: "Subscription notifications dropped due to slow-client buffer overflow.", + }, []string{"project", "network", "kind", "user", "agent_name"}) + MetricUpstreamCordoned = promauto.NewGaugeVec(prometheus.GaugeOpts{ Namespace: "erpc", Name: "upstream_cordoned", diff --git a/typescript/config/src/generated.ts b/typescript/config/src/generated.ts index 6fe14b957..6bbb7f8cc 100644 --- a/typescript/config/src/generated.ts +++ b/typescript/config/src/generated.ts @@ -1296,6 +1296,7 @@ export const AuthTypeDatabase: AuthType = "database"; export const AuthTypeJwt: AuthType = "jwt"; export const AuthTypeSiwe: AuthType = "siwe"; export const AuthTypeNetwork: AuthType = "network"; +export const AuthTypeForwardedClientId: AuthType = "forwardedClientId"; export interface AuthConfig { strategies: TsAuthStrategyConfig[]; } @@ -1309,6 +1310,17 @@ export interface AuthStrategyConfig { database?: DatabaseStrategyConfig; jwt?: JwtStrategyConfig; siwe?: SiweStrategyConfig; + /** + * Trust a gateway-injected client id header (e.g. Envoy X-Client-Id). + */ + forwardedClientId?: ForwardedClientIdStrategyConfig; +} +export interface ForwardedClientIdStrategyConfig { + /** + * Header carrying the client id. Default: "X-Client-Id". + */ + header?: string; + rateLimitBudget?: string; } export interface SecretStrategyConfig { id: string; diff --git a/typescript/config/src/index.ts b/typescript/config/src/index.ts index 2b3bd643d..f8ad24b61 100644 --- a/typescript/config/src/index.ts +++ b/typescript/config/src/index.ts @@ -66,6 +66,7 @@ export { AuthTypeJwt, AuthTypeSiwe, AuthTypeNetwork, + AuthTypeForwardedClientId, // Consensus related ConsensusLowParticipantsBehaviorReturnError, ConsensusLowParticipantsBehaviorAcceptMostCommonValidResult, From 62b381add6148fc7568cbacbd4500e30145e7655 Mon Sep 17 00:00:00 2001 From: snowkide Date: Wed, 5 Aug 2026 12:12:52 +0200 Subject: [PATCH 2/5] fix: label WS subscription events with network alias Use Network.Label() (alias when set) on ws_subscription_events_* so metrics match HTTP counters without a PromQL chain-id map. Co-authored-by: Cursor --- erpc/subscription_manager.go | 1 + indexer/adapters/wsclient/adapter.go | 20 +++++++++++++++++--- 2 files changed, 18 insertions(+), 3 deletions(-) diff --git a/erpc/subscription_manager.go b/erpc/subscription_manager.go index c16d92c82..90ffaea0b 100644 --- a/erpc/subscription_manager.go +++ b/erpc/subscription_manager.go @@ -176,6 +176,7 @@ func (sm *SubscriptionManager) Subscribe( conn.adapter.AddSubscription(clientSubID, networkId, kind, filterHash, wsclient.SubscriptionLabels{ Project: project.Config.Id, + Network: nw.Label(), User: nq.UserId(), AgentName: nq.AgentName(), }) diff --git a/indexer/adapters/wsclient/adapter.go b/indexer/adapters/wsclient/adapter.go index 8a037358d..911461752 100644 --- a/indexer/adapters/wsclient/adapter.go +++ b/indexer/adapters/wsclient/adapter.go @@ -66,6 +66,8 @@ type routeKey struct { // on delivered / dropped notification events. type SubscriptionLabels struct { Project string + // Network is the metrics network label (alias if configured, else network id). + Network string User string AgentName string } @@ -268,9 +270,13 @@ func (a *Adapter) runWriter(sub *clientSub) { // Drain us when the peer is truly gone. continue } + network := sub.labels.Network + if network == "" { + network = sub.networkID + } telemetry.MetricWsSubscriptionEventsTotal.WithLabelValues( sub.labels.Project, - sub.networkID, + network, sub.kind.String(), sub.labels.User, sub.labels.AgentName, @@ -290,18 +296,26 @@ func enqueue(sub *clientSub, payload json.RawMessage) { // Buffer full; drop oldest to make room. select { case <-sub.notify: + network := sub.labels.Network + if network == "" { + network = sub.networkID + } telemetry.MetricWsSubscriptionEventsDroppedTotal.WithLabelValues( sub.labels.Project, - sub.networkID, + network, sub.kind.String(), sub.labels.User, sub.labels.AgentName, ).Inc() default: // Concurrent drain won the race — drop this message. + network := sub.labels.Network + if network == "" { + network = sub.networkID + } telemetry.MetricWsSubscriptionEventsDroppedTotal.WithLabelValues( sub.labels.Project, - sub.networkID, + network, sub.kind.String(), sub.labels.User, sub.labels.AgentName, From 1a8d6fc70aa4b440ec93ab12852a9aba249f0e50 Mon Sep 17 00:00:00 2001 From: snowkide Date: Wed, 5 Aug 2026 17:47:47 +0200 Subject: [PATCH 3/5] feat(auth): accept path / (and apikey query/header) without edge Lua Domain-aliased hosts can keep a single path segment as the secret so clients use https://host/ without Envoy Lua/WASM path extractors. Co-authored-by: Cursor --- auth/http.go | 43 ++++++++++++++++++++++- auth/strategy_forwarded_client_id_test.go | 30 +++++++++++++++- erpc/healthcheck.go | 2 +- erpc/http_server.go | 4 +-- erpc/ws_server.go | 4 +-- 5 files changed, 76 insertions(+), 7 deletions(-) diff --git a/auth/http.go b/auth/http.go index 2dab88323..2ace053e6 100644 --- a/auth/http.go +++ b/auth/http.go @@ -5,12 +5,13 @@ import ( "errors" "net/http" "net/url" + "path" "strings" "github.com/erpc/erpc/common" ) -func NewPayloadFromHttp(method string, remoteAddr string, headers http.Header, args url.Values) (*AuthPayload, error) { +func NewPayloadFromHttp(method string, remoteAddr string, headers http.Header, args url.Values, requestPath string) (*AuthPayload, error) { ap := &AuthPayload{ Method: method, } @@ -25,11 +26,22 @@ func NewPayloadFromHttp(method string, remoteAddr string, headers http.Header, a ap.Secret = &SecretPayload{ Value: secret, } + } else if apikey := args.Get("apikey"); apikey != "" { + // Alias used by edge gateways / clients that speak "apikey" rather than "secret". + ap.Type = common.AuthTypeSecret + ap.Secret = &SecretPayload{ + Value: apikey, + } } else if tkn := headers.Get("X-ERPC-Secret-Token"); tkn != "" { ap.Type = common.AuthTypeSecret ap.Secret = &SecretPayload{ Value: tkn, } + } else if apikey := firstNonEmptyHeader(headers, "apikey", "X-Api-Key"); apikey != "" { + ap.Type = common.AuthTypeSecret + ap.Secret = &SecretPayload{ + Value: apikey, + } } else if ath := headers.Get("Authorization"); ath != "" { ath = strings.TrimSpace(ath) parts := strings.SplitN(ath, " ", 2) @@ -77,6 +89,13 @@ func NewPayloadFromHttp(method string, remoteAddr string, headers http.Header, a Message: normalizeSiweMessage(msg), } } + } else if pathSecret := singlePathSegmentSecret(requestPath); pathSecret != "" { + // Path form: https://host/ (with domain aliasing so the segment + // is not consumed as project/network). Avoids edge Lua/WASM filters. + ap.Type = common.AuthTypeSecret + ap.Secret = &SecretPayload{ + Value: pathSecret, + } } else if clientId := firstNonEmptyHeader(headers, "X-Client-Id", "x-client-id"); clientId != "" { // Gateway-injected identity after edge API-key auth (Envoy forwardClientIDHeader). ap.Type = common.AuthTypeForwardedClientId @@ -93,6 +112,28 @@ func NewPayloadFromHttp(method string, remoteAddr string, headers http.Header, a return ap, nil } +// singlePathSegmentSecret returns the sole path segment when the URL is +// `/` (or `//`). Multi-segment eRPC paths and reserved +// endpoints are ignored so routing/healthchecks stay unchanged. +func singlePathSegmentSecret(requestPath string) string { + if requestPath == "" { + return "" + } + ps := path.Clean(requestPath) + if ps == "/" || ps == "." { + return "" + } + seg := strings.TrimPrefix(ps, "/") + if seg == "" || strings.Contains(seg, "/") { + return "" + } + switch seg { + case "admin", "healthcheck", "metrics": + return "" + } + return seg +} + func firstNonEmptyHeader(headers http.Header, names ...string) string { for _, name := range names { if v := strings.TrimSpace(headers.Get(name)); v != "" { diff --git a/auth/strategy_forwarded_client_id_test.go b/auth/strategy_forwarded_client_id_test.go index 1f026467f..92e1718c4 100644 --- a/auth/strategy_forwarded_client_id_test.go +++ b/auth/strategy_forwarded_client_id_test.go @@ -13,13 +13,41 @@ import ( func TestNewPayloadFromHttp_ForwardedClientId(t *testing.T) { headers := http.Header{} headers.Set("X-Client-Id", "cl-no-alpha") - ap, err := NewPayloadFromHttp("eth_blockNumber", "1.2.3.4:1234", headers, url.Values{}) + ap, err := NewPayloadFromHttp("eth_blockNumber", "1.2.3.4:1234", headers, url.Values{}, "/") require.NoError(t, err) require.Equal(t, common.AuthTypeForwardedClientId, ap.Type) require.NotNil(t, ap.ForwardedClientId) require.Equal(t, "cl-no-alpha", ap.ForwardedClientId.Value) } +func TestNewPayloadFromHttp_PathSecret(t *testing.T) { + ap, err := NewPayloadFromHttp("eth_blockNumber", "1.2.3.4:1234", http.Header{}, url.Values{}, "/my-secret-key") + require.NoError(t, err) + require.Equal(t, common.AuthTypeSecret, ap.Type) + require.NotNil(t, ap.Secret) + require.Equal(t, "my-secret-key", ap.Secret.Value) +} + +func TestNewPayloadFromHttp_PathSecretIgnoredForMultiSegment(t *testing.T) { + ap, err := NewPayloadFromHttp("eth_blockNumber", "1.2.3.4:1234", http.Header{}, url.Values{}, "/main/evm/1") + require.NoError(t, err) + require.Equal(t, common.AuthTypeNetwork, ap.Type) +} + +func TestNewPayloadFromHttp_ApiKeyQueryAndHeader(t *testing.T) { + ap, err := NewPayloadFromHttp("eth_blockNumber", "1.2.3.4:1234", http.Header{}, url.Values{"apikey": []string{"q-key"}}, "/") + require.NoError(t, err) + require.Equal(t, common.AuthTypeSecret, ap.Type) + require.Equal(t, "q-key", ap.Secret.Value) + + headers := http.Header{} + headers.Set("apikey", "h-key") + ap, err = NewPayloadFromHttp("eth_blockNumber", "1.2.3.4:1234", headers, url.Values{}, "/") + require.NoError(t, err) + require.Equal(t, common.AuthTypeSecret, ap.Type) + require.Equal(t, "h-key", ap.Secret.Value) +} + func TestForwardedClientIdStrategy_Authenticate(t *testing.T) { s := NewForwardedClientIdStrategy(&common.ForwardedClientIdStrategyConfig{ Header: "X-Client-Id", diff --git a/erpc/healthcheck.go b/erpc/healthcheck.go index 85e4953b5..ce81bb596 100644 --- a/erpc/healthcheck.go +++ b/erpc/healthcheck.go @@ -86,7 +86,7 @@ func (s *HttpServer) handleHealthCheck( headers := r.Header queryArgs := r.URL.Query() - ap, err := auth.NewPayloadFromHttp("healthcheck", r.RemoteAddr, headers, queryArgs) + ap, err := auth.NewPayloadFromHttp("healthcheck", r.RemoteAddr, headers, queryArgs, r.URL.Path) if err != nil { handleErrorResponse(ctx, &logger, startedAt, nil, err, w, encoder, writeFatalError, &common.TRUE, s.executionHeadersMode()) return diff --git a/erpc/http_server.go b/erpc/http_server.go index 2616118eb..9c91cfbf6 100644 --- a/erpc/http_server.go +++ b/erpc/http_server.go @@ -574,9 +574,9 @@ func (s *HttpServer) createRequestHandler() http.Handler { var err error if project != nil { - ap, err = auth.NewPayloadFromHttp(method, r.RemoteAddr, headers, queryArgs) + ap, err = auth.NewPayloadFromHttp(method, r.RemoteAddr, headers, queryArgs, r.URL.Path) } else if isAdmin { - ap, err = auth.NewPayloadFromHttp(method, r.RemoteAddr, headers, queryArgs) + ap, err = auth.NewPayloadFromHttp(method, r.RemoteAddr, headers, queryArgs, r.URL.Path) } if err != nil { responses[index] = processErrorBody(&rlg, &startedAt, nq, err, &common.TRUE) diff --git a/erpc/ws_server.go b/erpc/ws_server.go index e3c53354e..92843c038 100644 --- a/erpc/ws_server.go +++ b/erpc/ws_server.go @@ -342,7 +342,7 @@ func (wsc *WsConnection) authenticate(requestCtx context.Context, nq *common.Nor return nil } - ap, err := auth.NewPayloadFromHttp(method, wsc.httpReq.RemoteAddr, wsc.httpReq.Header, wsc.httpReq.URL.Query()) + ap, err := auth.NewPayloadFromHttp(method, wsc.httpReq.RemoteAddr, wsc.httpReq.Header, wsc.httpReq.URL.Query(), wsc.httpReq.URL.Path) if err != nil { return err } @@ -455,7 +455,7 @@ func (wsc *WsConnection) handleBatchItem(index int, reqRaw json.RawMessage, star } if wsc.project != nil { - ap, err := auth.NewPayloadFromHttp(method, wsc.httpReq.RemoteAddr, wsc.httpReq.Header, wsc.httpReq.URL.Query()) + ap, err := auth.NewPayloadFromHttp(method, wsc.httpReq.RemoteAddr, wsc.httpReq.Header, wsc.httpReq.URL.Query(), wsc.httpReq.URL.Path) if err != nil { responses[index] = processErrorBody(wsc.logger, startedAt, nq, err, &common.TRUE) common.EndRequestSpan(requestCtx, nil, err) From ed403f846fdd505f853a8f186248f2f88cfff468 Mon Sep 17 00:00:00 2001 From: snowkide Date: Thu, 6 Aug 2026 10:05:08 +0200 Subject: [PATCH 4/5] fix(auth): harden secret validation and tidy WS event metrics Reject empty secrets, require secret.id, cover path reserved segments, and dedupe subscription event counter increments. Co-authored-by: Cursor --- auth/strategy_forwarded_client_id_test.go | 31 ++++++++++++ auth/strategy_secret.go | 9 ++++ common/config.go | 7 ++- common/validation.go | 8 +++- erpc/http_server_test.go | 2 +- indexer/adapters/wsclient/adapter.go | 58 ++++++++++------------- 6 files changed, 78 insertions(+), 37 deletions(-) diff --git a/auth/strategy_forwarded_client_id_test.go b/auth/strategy_forwarded_client_id_test.go index 92e1718c4..a6f987bbc 100644 --- a/auth/strategy_forwarded_client_id_test.go +++ b/auth/strategy_forwarded_client_id_test.go @@ -26,6 +26,12 @@ func TestNewPayloadFromHttp_PathSecret(t *testing.T) { require.Equal(t, common.AuthTypeSecret, ap.Type) require.NotNil(t, ap.Secret) require.Equal(t, "my-secret-key", ap.Secret.Value) + + // Trailing slash is cleaned to a single segment. + ap, err = NewPayloadFromHttp("eth_blockNumber", "1.2.3.4:1234", http.Header{}, url.Values{}, "/my-secret-key/") + require.NoError(t, err) + require.Equal(t, common.AuthTypeSecret, ap.Type) + require.Equal(t, "my-secret-key", ap.Secret.Value) } func TestNewPayloadFromHttp_PathSecretIgnoredForMultiSegment(t *testing.T) { @@ -34,6 +40,31 @@ func TestNewPayloadFromHttp_PathSecretIgnoredForMultiSegment(t *testing.T) { require.Equal(t, common.AuthTypeNetwork, ap.Type) } +func TestNewPayloadFromHttp_PathSecretIgnoredForReserved(t *testing.T) { + for _, seg := range []string{"/admin", "/healthcheck", "/metrics"} { + ap, err := NewPayloadFromHttp("eth_blockNumber", "1.2.3.4:1234", http.Header{}, url.Values{}, seg) + require.NoError(t, err) + require.Equal(t, common.AuthTypeNetwork, ap.Type, "path %s", seg) + } +} + +func TestSecretStrategy_RejectsEmpty(t *testing.T) { + s := NewSecretStrategy(&common.SecretStrategyConfig{Id: "cl-no-01", Value: ""}) + _, err := s.Authenticate(context.Background(), nil, &AuthPayload{ + Type: common.AuthTypeSecret, + Secret: &SecretPayload{Value: ""}, + }) + require.Error(t, err) + + s = NewSecretStrategy(&common.SecretStrategyConfig{Id: "cl-no-01", Value: "real-secret"}) + user, err := s.Authenticate(context.Background(), nil, &AuthPayload{ + Type: common.AuthTypeSecret, + Secret: &SecretPayload{Value: "real-secret"}, + }) + require.NoError(t, err) + require.Equal(t, "cl-no-01", user.Id) +} + func TestNewPayloadFromHttp_ApiKeyQueryAndHeader(t *testing.T) { ap, err := NewPayloadFromHttp("eth_blockNumber", "1.2.3.4:1234", http.Header{}, url.Values{"apikey": []string{"q-key"}}, "/") require.NoError(t, err) diff --git a/auth/strategy_secret.go b/auth/strategy_secret.go index 271e18a26..51dc9026e 100644 --- a/auth/strategy_secret.go +++ b/auth/strategy_secret.go @@ -2,6 +2,7 @@ package auth import ( "context" + "strings" "github.com/erpc/erpc/common" ) @@ -21,6 +22,14 @@ func (s *SecretStrategy) Supports(ap *AuthPayload) bool { } func (s *SecretStrategy) Authenticate(ctx context.Context, req *common.NormalizedRequest, ap *AuthPayload) (*common.User, error) { + if ap == nil || ap.Secret == nil { + return nil, common.NewErrAuthUnauthorized("secret", "missing secret") + } + // Reject empty configured or presented secrets so a missing env expansion + // (value="") can never authenticate an empty path/query credential. + if strings.TrimSpace(s.cfg.Value) == "" || strings.TrimSpace(ap.Secret.Value) == "" { + return nil, common.NewErrAuthUnauthorized("secret", "invalid secret") + } if ap.Secret.Value != s.cfg.Value { return nil, common.NewErrAuthUnauthorized("secret", "invalid secret") } diff --git a/common/config.go b/common/config.go index f8b45d728..84ab77cd2 100644 --- a/common/config.go +++ b/common/config.go @@ -2418,9 +2418,12 @@ type AuthStrategyConfig struct { // ForwardedClientIdStrategyConfig trusts a non-secret client identity header // injected by an upstream gateway after API-key auth (e.g. Envoy // apiKeyAuth.forwardClientIDHeader → X-Client-Id). Must only be enabled -// behind a gateway that strips client-supplied values of that header. +// behind a gateway that overwrites/strips client-supplied values of that header. type ForwardedClientIdStrategyConfig struct { - // Header is the request header carrying the client id. Default: "X-Client-Id". + // Header documents the expected gateway identity header (default conceptually + // "X-Client-Id"). Payload extraction in auth.NewPayloadFromHttp currently + // always reads X-Client-Id via case-insensitive Header.Get; this field is + // not yet used to select the header name at runtime. Header string `yaml:"header,omitempty" json:"header,omitempty"` // RateLimitBudget, if set, is applied to the authenticated user. RateLimitBudget string `yaml:"rateLimitBudget,omitempty" json:"rateLimitBudget,omitempty"` diff --git a/common/validation.go b/common/validation.go index b464e7730..25bcafd87 100644 --- a/common/validation.go +++ b/common/validation.go @@ -784,7 +784,13 @@ func (s *NetworkStrategyConfig) Validate() error { } func (s *SecretStrategyConfig) Validate() error { - if s.Value == "" { + if s == nil { + return fmt.Errorf("auth.*.secret is required") + } + if strings.TrimSpace(s.Id) == "" { + return fmt.Errorf("auth.*.secret.id is required") + } + if strings.TrimSpace(s.Value) == "" { return fmt.Errorf("auth.*.secret.value is required") } return nil diff --git a/erpc/http_server_test.go b/erpc/http_server_test.go index 3b5aa7912..9377760cd 100644 --- a/erpc/http_server_test.go +++ b/erpc/http_server_test.go @@ -4112,7 +4112,7 @@ func TestHttpServer_HandleHealthCheck(t *testing.T) { pp.networksRegistry = NewNetworksRegistry(pp, ctx, pp.upstreamsRegistry, mtk, nil, nil, nil, logger) authReg, _ := auth.NewAuthRegistry(ctx, logger, "test", &common.AuthConfig{Strategies: []*common.AuthStrategyConfig{ - {Type: common.AuthTypeSecret, Secret: &common.SecretStrategyConfig{Value: "test-secret"}}, + {Type: common.AuthTypeSecret, Secret: &common.SecretStrategyConfig{Id: "test-user", Value: "test-secret"}}, }}, nil) return &HttpServer{ diff --git a/indexer/adapters/wsclient/adapter.go b/indexer/adapters/wsclient/adapter.go index 911461752..9eb2d3f85 100644 --- a/indexer/adapters/wsclient/adapter.go +++ b/indexer/adapters/wsclient/adapter.go @@ -270,21 +270,33 @@ func (a *Adapter) runWriter(sub *clientSub) { // Drain us when the peer is truly gone. continue } - network := sub.labels.Network - if network == "" { - network = sub.networkID - } - telemetry.MetricWsSubscriptionEventsTotal.WithLabelValues( - sub.labels.Project, - network, - sub.kind.String(), - sub.labels.User, - sub.labels.AgentName, - ).Inc() + incWsSubscriptionEvent(sub, false) } } } +func subscriptionNetworkLabel(sub *clientSub) string { + if sub.labels.Network != "" { + return sub.labels.Network + } + return sub.networkID +} + +func incWsSubscriptionEvent(sub *clientSub, dropped bool) { + labels := []string{ + sub.labels.Project, + subscriptionNetworkLabel(sub), + sub.kind.String(), + sub.labels.User, + sub.labels.AgentName, + } + if dropped { + telemetry.MetricWsSubscriptionEventsDroppedTotal.WithLabelValues(labels...).Inc() + } else { + telemetry.MetricWsSubscriptionEventsTotal.WithLabelValues(labels...).Inc() + } +} + // enqueue pushes a payload onto the sub's buffer, evicting the oldest // element when the buffer is full. Never blocks. func enqueue(sub *clientSub, payload json.RawMessage) { @@ -296,30 +308,10 @@ func enqueue(sub *clientSub, payload json.RawMessage) { // Buffer full; drop oldest to make room. select { case <-sub.notify: - network := sub.labels.Network - if network == "" { - network = sub.networkID - } - telemetry.MetricWsSubscriptionEventsDroppedTotal.WithLabelValues( - sub.labels.Project, - network, - sub.kind.String(), - sub.labels.User, - sub.labels.AgentName, - ).Inc() + incWsSubscriptionEvent(sub, true) default: // Concurrent drain won the race — drop this message. - network := sub.labels.Network - if network == "" { - network = sub.networkID - } - telemetry.MetricWsSubscriptionEventsDroppedTotal.WithLabelValues( - sub.labels.Project, - network, - sub.kind.String(), - sub.labels.User, - sub.labels.AgentName, - ).Inc() + incWsSubscriptionEvent(sub, true) return } } From 6ccf71f8c1014e6ec659bc39ee01a72b634fba3f Mon Sep 17 00:00:00 2001 From: snowkide Date: Fri, 7 Aug 2026 10:27:56 +0200 Subject: [PATCH 5/5] feat(metrics): label request_received with client transport http|ws CLL rpc_ws_event_count_total must count JSON-RPC calls over WebSocket, not subscription push notifications. Mark WS ingress on NormalizedRequest and expose transport on erpc_network_request_received_total. Co-authored-by: Cursor --- common/request.go | 25 +++++++++++++++++++++++++ erpc/projects.go | 2 +- erpc/subscription_manager.go | 4 ++-- erpc/ws_server.go | 2 ++ telemetry/metrics.go | 7 ++++--- 5 files changed, 34 insertions(+), 6 deletions(-) diff --git a/common/request.go b/common/request.go index f7dd6161c..7bd3231b9 100644 --- a/common/request.go +++ b/common/request.go @@ -360,6 +360,10 @@ type NormalizedRequest struct { // Resolved client IP (set by HTTP ingress using trusted forwarders) clientIP atomic.Value + // Client transport that delivered this request ("http" or "ws"). + // Defaults to "http" when unset so HTTP ingress needs no explicit set. + transport atomic.Value + // Per-request execution counters; lazy-init via execStateHolder. execStateHolder execStateHolder } @@ -1322,6 +1326,27 @@ func (r *NormalizedRequest) AgentName() string { return "unknown" } +// SetTransport records the client ingress transport ("http" or "ws"). +func (r *NormalizedRequest) SetTransport(transport string) { + if r == nil || transport == "" { + return + } + r.transport.Store(transport) +} + +// Transport returns the client ingress transport. Defaults to "http". +func (r *NormalizedRequest) Transport() string { + if r == nil { + return "http" + } + if v := r.transport.Load(); v != nil { + if s, ok := v.(string); ok && s != "" { + return s + } + } + return "http" +} + // getUserAgent returns the user agent string, with query parameter taking precedence over header func (r *NormalizedRequest) getUserAgent(headers http.Header, queryArgs url.Values) string { // Query parameter takes precedence diff --git a/erpc/projects.go b/erpc/projects.go index 1c601a7c7..421234cfe 100644 --- a/erpc/projects.go +++ b/erpc/projects.go @@ -121,7 +121,7 @@ func (p *PreparedProject) Forward(ctx context.Context, networkId string, nq *com reqFinality := nq.Finality(ctx) telemetry.CounterHandle(telemetry.MetricNetworkRequestsReceived, - p.Config.Id, network.Label(), method, reqFinality.String(), nq.UserId(), nq.AgentName(), + p.Config.Id, network.Label(), method, reqFinality.String(), nq.UserId(), nq.AgentName(), nq.Transport(), ).Inc() lg := p.Logger.With(). Str("component", "proxy"). diff --git a/erpc/subscription_manager.go b/erpc/subscription_manager.go index 90ffaea0b..c10f47594 100644 --- a/erpc/subscription_manager.go +++ b/erpc/subscription_manager.go @@ -152,7 +152,7 @@ func (sm *SubscriptionManager) Subscribe( reqFinality := nq.Finality(ctx) telemetry.CounterHandle(telemetry.MetricNetworkRequestsReceived, project.Config.Id, nw.Label(), method, - reqFinality.String(), nq.UserId(), nq.AgentName(), + reqFinality.String(), nq.UserId(), nq.AgentName(), nq.Transport(), ).Inc() jrReq, err := nq.JsonRpcRequest() @@ -231,7 +231,7 @@ func (sm *SubscriptionManager) Unsubscribe( reqFinality := nq.Finality(ctx) telemetry.CounterHandle(telemetry.MetricNetworkRequestsReceived, project.Config.Id, nw.Label(), method, - reqFinality.String(), nq.UserId(), nq.AgentName(), + reqFinality.String(), nq.UserId(), nq.AgentName(), nq.Transport(), ).Inc() jrReq, err := nq.JsonRpcRequest() diff --git a/erpc/ws_server.go b/erpc/ws_server.go index 92843c038..eb06d000b 100644 --- a/erpc/ws_server.go +++ b/erpc/ws_server.go @@ -224,6 +224,7 @@ func (wsc *WsConnection) handleMessage(raw []byte) { func (wsc *WsConnection) handleSingleRequest(raw []byte, startedAt *time.Time) { nq := common.NewNormalizedRequest(raw) + nq.SetTransport("ws") nq.ForwardHeaders = make(http.Header) requestCtx := common.StartRequestSpan(wsc.appCtx, nq) @@ -427,6 +428,7 @@ func (wsc *WsConnection) handleBatch(raw []byte, startedAt *time.Time) { // connection context. func (wsc *WsConnection) handleBatchItem(index int, reqRaw json.RawMessage, startedAt *time.Time, responses []interface{}) { nq := common.NewNormalizedRequest(reqRaw) + nq.SetTransport("ws") nq.ForwardHeaders = make(http.Header) requestCtx := common.StartRequestSpan(wsc.appCtx, nq) diff --git a/telemetry/metrics.go b/telemetry/metrics.go index 8517d6869..aadeb8e49 100644 --- a/telemetry/metrics.go +++ b/telemetry/metrics.go @@ -89,8 +89,9 @@ var ( }, []string{"project", "vendor", "network", "upstream"}) // Client-facing WebSocket subscription push events (newHeads / logs / - // pending txs) successfully written to a downstream client. Maps to - // third-party RPC observability rpc_ws_event_count_total. + // pending txs) successfully written to a downstream client. Internal + // ops metric — CLL rpc_ws_event_count_total maps to request_received + // with transport="ws", not these pushes. MetricWsSubscriptionEventsTotal = promauto.NewCounterVec(prometheus.CounterOpts{ Namespace: "erpc", Name: "ws_subscription_events_total", @@ -350,7 +351,7 @@ var ( Namespace: "erpc", Name: "network_request_received_total", Help: "Total number of requests received for a network.", - }, []string{"project", "network", "category", "finality", "user", "agent_name"}) + }, []string{"project", "network", "category", "finality", "user", "agent_name", "transport"}) MetricNetworkMultiplexedRequests = promauto.NewCounterVec(prometheus.CounterOpts{ Namespace: "erpc",