Skip to content
This repository was archived by the owner on Jul 22, 2026. It is now read-only.
Open
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
2 changes: 1 addition & 1 deletion .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ jobs:
run: make test-fast PKG_EXCLUDE_REGEX='^github\.com/erpc/erpc/(cmd|test|erpc)($$|/)'
- name: Test erpc critical paths
run: |
go test ./erpc -run '^(TestConsensusPolicy|TestConsensusPolicy_DSLScenarios)$' -count=1 -parallel 1 -timeout 3m -v
go test ./erpc -run '^(TestConsensusPolicy|TestConsensusPolicy_DSLScenarios|TestBridgeWebsocketsProxiesBidirectionally|TestParseUrlPathAcceptsWebsocketUpgrade|TestWebsocketOriginAllowedUsesProjectCORS|TestAuthorizeWebsocketFrameEnforcesMethodFilters|TestDialWebsocketUpstreamFailsOver|TestWebsocketManagerLimitsAndShutsDownConnections)$' -count=1 -parallel 1 -timeout 3m -v
make test-fallback-config
- name: Validate Go modules
run: git diff --exit-code -- go.mod go.sum
Expand Down
19 changes: 18 additions & 1 deletion auth/registry.go
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,15 @@ func NewAuthRegistry(appCtx context.Context, logger *zerolog.Logger, projectId s

// Authenticate checks the authentication payload against all registered strategies
func (r *AuthRegistry) Authenticate(ctx context.Context, req *common.NormalizedRequest, method string, ap *AuthPayload) (*common.User, error) {
return r.authenticate(ctx, req, method, ap, false)
}

// AuthenticateWebsocket authenticates only against strategies explicitly enabled for WebSocket access.
func (r *AuthRegistry) AuthenticateWebsocket(ctx context.Context, req *common.NormalizedRequest, method string, ap *AuthPayload) (*common.User, error) {
return r.authenticate(ctx, req, method, ap, true)
}

func (r *AuthRegistry) authenticate(ctx context.Context, req *common.NormalizedRequest, method string, ap *AuthPayload, requireWebsocket bool) (*common.User, error) {
if ap == nil {
return nil, common.NewErrAuthUnauthorized("n/a", "auth payload is nil")
}
Expand All @@ -56,7 +65,11 @@ func (r *AuthRegistry) Authenticate(ctx context.Context, req *common.NormalizedR
var errs []error

for _, az := range r.strategies {
if !az.shouldApplyToMethod(method) {
if requireWebsocket && !az.cfg.AllowWebsocket {
continue
}

if (!requireWebsocket || method != "websocket_connect") && !az.shouldApplyToMethod(method) {
continue
}

Expand All @@ -69,6 +82,10 @@ func (r *AuthRegistry) Authenticate(ctx context.Context, req *common.NormalizedR
errs = append(errs, err)
continue
}
if requireWebsocket && user != nil && user.AuthFailOpen {
errs = append(errs, common.NewErrAuthUnauthorized("database", "WebSocket access is disabled during database auth fail-open"))
continue
}

// Attach user to the request early so downstream labels (user/agent) can be populated
if user != nil && req != nil {
Expand Down
47 changes: 47 additions & 0 deletions auth/registry_websocket_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
package auth

import (
"context"
"testing"

"github.com/erpc/erpc/common"
"github.com/rs/zerolog"
"github.com/stretchr/testify/require"
)

func TestAuthRegistryAuthenticateWebsocketRequiresExplicitAccess(t *testing.T) {
t.Parallel()
logger := zerolog.Nop()
registry, err := NewAuthRegistry(context.Background(), &logger, "test", &common.AuthConfig{
Strategies: []*common.AuthStrategyConfig{
{
Type: common.AuthTypeSecret,
Secret: &common.SecretStrategyConfig{Id: "http-only", Value: "denied-key"},
},
{
Type: common.AuthTypeSecret,
AllowWebsocket: true,
IgnoreMethods: []string{"*"},
AllowMethods: []string{"eth_subscribe"},
Secret: &common.SecretStrategyConfig{Id: "websocket", Value: "allowed-key"},
},
},
}, nil)
require.NoError(t, err)

req := common.NewNormalizedRequest([]byte(`{"jsonrpc":"2.0","id":1,"method":"eth_subscribe","params":["newHeads"]}`))

_, err = registry.AuthenticateWebsocket(context.Background(), req, "eth_subscribe", &AuthPayload{
Type: common.AuthTypeSecret, Secret: &SecretPayload{Value: "denied-key"},
})
require.Error(t, err)
require.True(t, common.HasErrorCode(err, common.ErrCodeAuthUnauthorized))

user, err := registry.AuthenticateWebsocket(context.Background(), req, "eth_subscribe", &AuthPayload{
Type: common.AuthTypeSecret, Secret: &SecretPayload{Value: "allowed-key"},
})
require.NoError(t, err)
require.Equal(t, "websocket", user.Id)
_, err = registry.AuthenticateWebsocket(context.Background(), req, "eth_sendRawTransaction", &AuthPayload{Type: common.AuthTypeSecret, Secret: &SecretPayload{Value: "allowed-key"}})
require.Error(t, err)
}
2 changes: 1 addition & 1 deletion auth/strategy_database.go
Original file line number Diff line number Diff line change
Expand Up @@ -608,7 +608,7 @@ func (s *DatabaseStrategy) buildFailOpenUser() *common.User {
if s.cfg == nil || s.cfg.FailOpen == nil || !s.cfg.FailOpen.Enabled {
return nil
}
u := &common.User{Id: s.cfg.FailOpen.UserId}
u := &common.User{Id: s.cfg.FailOpen.UserId, AuthFailOpen: true}
if s.cfg.FailOpen.RateLimitBudget != "" {
u.RateLimitBudget = s.cfg.FailOpen.RateLimitBudget
}
Expand Down
1 change: 1 addition & 0 deletions auth/strategy_database_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -486,6 +486,7 @@ func TestAuthenticate_FastPathDuringOutage(t *testing.T) {
require.NoError(t, err)
require.NotNil(t, u)
assert.Equal(t, "emergency-failopen", u.Id)
assert.True(t, u.AuthFailOpen)
assert.Equal(t, int64(1), fc.getCalls.Load())
assert.True(t, s.connectorDown.Load(), "first failure must latch connectorDown")

Expand Down
17 changes: 15 additions & 2 deletions common/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,7 @@ type ServerConfig struct {
TrustedIPForwarders []string `yaml:"trustedIPForwarders,omitempty" json:"trustedIPForwarders"`
TrustedIPHeaders []string `yaml:"trustedIPHeaders,omitempty" json:"trustedIPHeaders"`
ResponseHeaders map[string]string `yaml:"responseHeaders,omitempty" json:"responseHeaders"`
Websocket *WebsocketServerConfig `yaml:"websocket,omitempty" json:"websocket,omitempty"`

// ExecutionHeaders controls the per-request diagnostic headers
// (X-ERPC-Attempts, X-ERPC-Upstreams-Tried, etc.) that expose how
Expand All @@ -169,6 +170,13 @@ type ServerConfig struct {
ExecutionHeaders *ExecutionHeadersMode `yaml:"executionHeaders,omitempty" json:"executionHeaders" tstype:"ExecutionHeadersMode"`
}

type WebsocketServerConfig struct {
DialTimeout *Duration `yaml:"dialTimeout,omitempty" json:"dialTimeout" tstype:"Duration"`
IdleTimeout *Duration `yaml:"idleTimeout,omitempty" json:"idleTimeout" tstype:"Duration"`
MaxLifetime *Duration `yaml:"maxLifetime,omitempty" json:"maxLifetime" tstype:"Duration"`
MaxConnectionsPerUser *int `yaml:"maxConnectionsPerUser,omitempty" json:"maxConnectionsPerUser"`
}

// ExecutionHeadersMode controls how much per-request execution detail is
// exposed in HTTP response headers.
type ExecutionHeadersMode string
Expand Down Expand Up @@ -823,6 +831,7 @@ type UpstreamConfig struct {

VendorName string `yaml:"vendorName,omitempty" json:"vendorName"`
Endpoint string `yaml:"endpoint,omitempty" json:"endpoint"`
WebsocketEndpoint string `yaml:"websocketEndpoint,omitempty" json:"websocketEndpoint,omitempty"`
Evm *EvmUpstreamConfig `yaml:"evm,omitempty" json:"evm"`
JsonRpc *JsonRpcUpstreamConfig `yaml:"jsonRpc,omitempty" json:"jsonRpc"`
Grpc *GrpcUpstreamConfig `yaml:"grpc,omitempty" json:"grpc"`
Expand Down Expand Up @@ -1139,10 +1148,12 @@ func (c *UpstreamIntegrityEthGetBlockReceiptsConfig) Copy() *UpstreamIntegrityEt
func (u *UpstreamConfig) MarshalJSON() ([]byte, error) {
type UJAlias UpstreamConfig
return sonic.Marshal(&struct {
Endpoint string `json:"endpoint"`
Endpoint string `json:"endpoint"`
WebsocketEndpoint string `json:"websocketEndpoint,omitempty"`
*UJAlias
}{
Endpoint: util.RedactEndpoint(u.Endpoint),
Endpoint: util.RedactEndpoint(u.Endpoint),
WebsocketEndpoint: util.RedactEndpoint(u.WebsocketEndpoint),
UJAlias: (*UJAlias)(u),
})
}
Expand All @@ -1151,6 +1162,7 @@ func (u *UpstreamConfig) MarshalYAML() (interface{}, error) {
type UYAlias UpstreamConfig
cp := *u
cp.Endpoint = util.RedactEndpoint(u.Endpoint)
cp.WebsocketEndpoint = util.RedactEndpoint(u.WebsocketEndpoint)
return (*UYAlias)(&cp), nil
}

Expand Down Expand Up @@ -2783,6 +2795,7 @@ type AuthStrategyConfig struct {
IgnoreMethods []string `yaml:"ignoreMethods,omitempty" json:"ignoreMethods,omitempty"`
AllowMethods []string `yaml:"allowMethods,omitempty" json:"allowMethods,omitempty"`
RateLimitBudget string `yaml:"rateLimitBudget,omitempty" json:"rateLimitBudget,omitempty"`
AllowWebsocket bool `yaml:"allowWebsocket,omitempty" json:"allowWebsocket,omitempty"`

Type AuthType `yaml:"type" json:"type" tstype:"TsAuthType"`
Network *NetworkStrategyConfig `yaml:"network,omitempty" json:"network,omitempty"`
Expand Down
13 changes: 13 additions & 0 deletions common/config_redaction_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -43,3 +43,16 @@ func assertGrpcConfigSecretsRedacted(t *testing.T, text string) {
assert.Contains(t, text, "REDACTED")
assert.Contains(t, text, "redacted=")
}


func TestUpstreamConfigRedactsWebsocketEndpoint(t *testing.T) {
cfg := &UpstreamConfig{Id: "websocket-secure", Endpoint: "https://example.internal/rpc?token=http-secret", WebsocketEndpoint: "wss://example.internal/private/ws?token=websocket-secret"}
jsonBytes, err := json.Marshal(cfg); require.NoError(t, err)
sonicBytes, err := SonicCfg.Marshal(cfg); require.NoError(t, err)
yamlBytes, err := yaml.Marshal(cfg); require.NoError(t, err)
for _, text := range []string{string(jsonBytes), string(sonicBytes), string(yamlBytes)} {
assert.NotContains(t, text, "http-secret")
assert.NotContains(t, text, "websocket-secret")
assert.Contains(t, text, "redacted=")
}
}
8 changes: 8 additions & 0 deletions common/defaults.go
Original file line number Diff line number Diff line change
Expand Up @@ -897,6 +897,11 @@ func (s *ServerConfig) SetDefaults() error {
if s.EnableGzip == nil {
s.EnableGzip = util.BoolPtr(true)
}
if s.Websocket == nil { s.Websocket = &WebsocketServerConfig{} }
if s.Websocket.DialTimeout == nil { d := Duration(10 * time.Second); s.Websocket.DialTimeout = &d }
if s.Websocket.IdleTimeout == nil { d := Duration(5 * time.Minute); s.Websocket.IdleTimeout = &d }
if s.Websocket.MaxLifetime == nil { d := Duration(24 * time.Hour); s.Websocket.MaxLifetime = &d }
if s.Websocket.MaxConnectionsPerUser == nil { s.Websocket.MaxConnectionsPerUser = util.IntPtr(10) }
if s.WaitBeforeShutdown == nil {
d := Duration(10 * time.Second)
s.WaitBeforeShutdown = &d
Expand Down Expand Up @@ -1757,6 +1762,9 @@ func (u *UpstreamConfig) ApplyDefaults(defaults *UpstreamConfig) error {
if u.Endpoint == "" {
u.Endpoint = defaults.Endpoint
}
if u.WebsocketEndpoint == "" {
u.WebsocketEndpoint = defaults.WebsocketEndpoint
}
if u.Type == "" {
u.Type = defaults.Type
}
Expand Down
1 change: 1 addition & 0 deletions common/user.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,4 +3,5 @@ package common
type User struct {
Id string
RateLimitBudget string
AuthFailOpen bool
}
12 changes: 12 additions & 0 deletions common/validation.go
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,12 @@ func (s *ServerConfig) Validate() error {
if s.MaxBatchConcurrency != nil && *s.MaxBatchConcurrency <= 0 {
return fmt.Errorf("server.maxBatchConcurrency must be > 0")
}
if s.Websocket != nil {
if s.Websocket.DialTimeout == nil || *s.Websocket.DialTimeout <= 0 { return fmt.Errorf("server.websocket.dialTimeout must be > 0") }
if s.Websocket.IdleTimeout == nil || *s.Websocket.IdleTimeout <= 0 { return fmt.Errorf("server.websocket.idleTimeout must be > 0") }
if s.Websocket.MaxLifetime == nil || *s.Websocket.MaxLifetime <= 0 { return fmt.Errorf("server.websocket.maxLifetime must be > 0") }
if s.Websocket.MaxConnectionsPerUser == nil || *s.Websocket.MaxConnectionsPerUser <= 0 { return fmt.Errorf("server.websocket.maxConnectionsPerUser must be > 0") }
}

// Validate trusted IP forwarders if provided (IPs or CIDRs). Support legacy + new field
for _, entry := range s.TrustedIPForwarders {
Expand Down Expand Up @@ -966,6 +972,12 @@ func (u *UpstreamConfig) Validate(c *Config, skipEndpointCheck bool) error {
if !skipEndpointCheck && u.Endpoint == "" {
return fmt.Errorf("upstream.*.endpoint is required")
}
if u.WebsocketEndpoint != "" {
parsed, err := url.Parse(u.WebsocketEndpoint)
if err != nil || (parsed.Scheme != "ws" && parsed.Scheme != "wss") {
return fmt.Errorf("upstream.*.websocketEndpoint must use ws:// or wss://")
}
}
if err := validateCapabilityTags("upstream.*.capabilities", u.Capabilities); err != nil {
return err
}
Expand Down
1 change: 1 addition & 0 deletions docs/pages/config/auth.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -201,6 +201,7 @@ Authenticate fast paths (in evaluation order):
| `strategies[*].ignoreMethods` | `[]string` | `nil` | Wildcard patterns (supports `*`, `\|`, `&`, `!`). Applied before `allowMethods`. <SourceLink file="auth/authorizer.go" lines="86-98" /> |
| `strategies[*].allowMethods` | `[]string` | `nil` | Overrides `ignoreMethods`; any matching allow re-enables the strategy for that method. <SourceLink file="auth/authorizer.go" lines="100-113" /> |
| `strategies[*].rateLimitBudget` | `string` | `""` | Strategy-level budget ID. Overridden by per-user budget when non-empty. <SourceLink file="auth/authorizer.go" lines="119-127" /> |
| `strategies[*].allowWebsocket` | `bool` | `false` | Explicitly permits this strategy to authenticate WebSocket upgrades. Keep false for HTTP-only API keys. WebSocket access is denied unless a matching strategy opts in. |

**`secret` strategy — `SecretStrategyConfig`**

Expand Down
25 changes: 25 additions & 0 deletions docs/pages/config/projects/upstreams.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,31 @@ https://docs.erpc.cloud/config/projects/upstreams.llms.txt`}

<AISection title="Upstreams — full agent reference">

### WebSocket subscriptions

WebSocket transport is opt-in at both ends. Configure `websocketEndpoint` on an upstream that supports `eth_subscribe`, then set `allowWebsocket: true` only on the auth strategies/API keys that may open WebSocket connections. Other keys continue to receive HTTP access only.

```yaml
projects:
- id: main
auth:
strategies:
- type: secret
allowWebsocket: true
secret:
id: subscriptions-client
value: ${SUBSCRIPTIONS_API_KEY}
- type: secret
secret:
id: http-only-client
value: ${HTTP_API_KEY}
upstreams:
- endpoint: https://eth-mainnet.example/v2/key
websocketEndpoint: wss://eth-mainnet.example/v2/key
```

WebSocket connections use the normal `/<project>/<architecture>/<chainId>` route with the same `secret`, `X-ERPC-Secret-Token`, Basic, Bearer, SIWE, or network credentials as HTTP. eRPC checks method filters and project/auth rate limits on every client frame, including each request in a JSON-RPC batch; binary and malformed frames are rejected. It tries eligible `eth_subscribe` upstreams in current policy order until one connects. WebSocket traffic is stateful: it is not cached, retried, hedged, or failed over after connection establishment. Connections are bounded by per-user concurrency, idle, maximum-lifetime, and dial limits and are closed during graceful shutdown. Database-auth fail-open users are rejected for WebSocket upgrades.

### How it works

**Config-time pipeline.** When eRPC loads a project, each upstream in `upstreams[]`
Expand Down
4 changes: 4 additions & 0 deletions docs/pages/config/server.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,10 @@ All fields live under `server.` unless noted. `Duration` accepts Go duration str
| `server.readTimeout` | `*Duration` | `30s` | `http.Server.ReadTimeout` — covers reading headers and body. <SourceLink file="common/defaults.go" lines="698-701" /> |
| `server.writeTimeout` | `*Duration` | `120s` | `http.Server.WriteTimeout` — covers writing the response. The entire response is buffered by `TimeoutHandler` before reaching the socket, so this only matters at final flush. <SourceLink file="common/defaults.go" lines="702-705" /> |
| `server.enableGzip` | `*bool` | `true` | Wraps handler in `gzipHandler` for response compression. Inbound gzip is always accepted regardless of this flag. <SourceLink file="common/defaults.go" lines="706-708" /> |
| `server.websocket.dialTimeout` | `*Duration` | `10s` | Per-upstream WebSocket dial timeout before trying the next eligible candidate. |
| `server.websocket.idleTimeout` | `*Duration` | `5m` | Closes a WebSocket connection when neither side sends a frame for this duration. |
| `server.websocket.maxLifetime` | `*Duration` | `24h` | Hard lifetime for a WebSocket connection. |
| `server.websocket.maxConnectionsPerUser` | `*int` | `10` | Maximum concurrent WebSocket connections per authenticated user/API-key ID. |
| `server.tls.enabled` | `bool` | `false` | When true, both listeners use `ListenAndServeTLS` with TLS 1.2 minimum; gRPC also uses TLS. Disables h2c on the shared port. <SourceLink file="erpc/http_server.go" lines="1537-1554" /> |
| `server.tls.certFile` | `string` | `""` | PEM cert path. Load failure → "failed to load TLS certificate and key". |
| `server.tls.keyFile` | `string` | `""` | PEM key path. |
Expand Down
Loading
Loading