diff --git a/core/services/gateway/handlers/confidentialrelay/handler.go b/core/services/gateway/handlers/confidentialrelay/handler.go index bbe413b738a..731ee8b4f1f 100644 --- a/core/services/gateway/handlers/confidentialrelay/handler.go +++ b/core/services/gateway/handlers/confidentialrelay/handler.go @@ -129,6 +129,7 @@ type handler struct { globalNodeRateLimiter limits.RateLimiter perNodeRateLimiters map[string]limits.RateLimiter + userRateLimiter limits.RateLimiter requestTimeout time.Duration activeRequests map[string]*activeRequest @@ -171,6 +172,14 @@ func NewHandler(methodConfig json.RawMessage, donConfig *config.DONConfig, don g perNodeRateLimiters[member.Address] = rl } + // userRateLimiter bounds inbound user requests before they are fanned out to the + // DON. Without it, a single unauthenticated request amplifies into per-node + // attestation work; the limiters above only guard node responses. + userRateLimiter, err := limitsFactory.MakeRateLimiter(cresettings.Default.GatewayConfidentialRelayUserRate) + if err != nil { + return nil, fmt.Errorf("failed to create user rate limiter: %w", err) + } + metrics, err := newMetrics() if err != nil { return nil, fmt.Errorf("failed to create metrics: %w", err) @@ -183,6 +192,7 @@ func NewHandler(methodConfig json.RawMessage, donConfig *config.DONConfig, don g requestTimeout: time.Duration(cfg.RequestTimeoutSec) * time.Second, globalNodeRateLimiter: globalNodeRateLimiter, perNodeRateLimiters: perNodeRateLimiters, + userRateLimiter: userRateLimiter, activeRequests: make(map[string]*activeRequest), mu: sync.RWMutex{}, stopCh: make(services.StopChan), @@ -224,6 +234,9 @@ func (h *handler) Close() error { for _, rl := range h.perNodeRateLimiters { err = errors.Join(err, rl.Close()) } + if h.userRateLimiter != nil { + err = errors.Join(err, h.userRateLimiter.Close()) + } return err }) } @@ -272,6 +285,13 @@ func (h *handler) HandleLegacyUserMessage(_ context.Context, _ *api.Message, _ g } func (h *handler) HandleJSONRPCUserMessage(ctx context.Context, req jsonrpc.Request[json.RawMessage], callback gwhandlers.Callback) error { + // Shed load before any fan-out. Each forwarded request costs every DON node an + // attestation verification and a capabilities-registry lookup, so throttling here + // caps that amplification for unauthenticated ingress. + if !h.userRateLimiter.Allow(ctx) { + h.lggr.Debugw("user request rate limited", "requestID", req.ID) + return errors.New("request rate limit exceeded") + } if req.ID == "" { return errors.New("request ID cannot be empty") } diff --git a/core/services/gateway/handlers/confidentialrelay/handler_test.go b/core/services/gateway/handlers/confidentialrelay/handler_test.go index 105c53e18b1..843dbf2601b 100644 --- a/core/services/gateway/handlers/confidentialrelay/handler_test.go +++ b/core/services/gateway/handlers/confidentialrelay/handler_test.go @@ -700,6 +700,47 @@ func TestConfidentialRelayHandler_RateLimitedNode(t *testing.T) { require.Error(t, err) // Should timeout } +func TestConfidentialRelayHandler_RateLimitedUserIngress(t *testing.T) { + t.Parallel() + handlerConfig := Config{RequestTimeoutSec: 30} + methodConfig, err := json.Marshal(handlerConfig) + require.NoError(t, err) + + lggr := logger.Test(t) + don := mocks.NewDON(t) + // F=0 single-node DON so a passing request fans out to exactly one node. + donConfig := &config.DONConfig{ + DonId: "test_relay_don", + F: 0, + Members: []config.NodeConfig{nodeOne}, + } + clock := clockwork.NewFakeClock() + limitsFactory := limits.Factory{Settings: cresettings.DefaultGetter, Logger: lggr} + h, err := NewHandler(methodConfig, donConfig, don, lggr, clock, limitsFactory) + require.NoError(t, err) + // Burst of 1 with a negligible refill rate: the first ingress request passes, + // the next is rejected before any fan-out to nodes. + h.userRateLimiter = limits.GlobalRateLimiter(rate.Limit(0.001), 1) + + don.On("SendToNode", mock.Anything, mock.Anything, mock.Anything).Return(nil).Once() + + params := json.RawMessage(`{"workflow_id":"wf1"}`) + + // First request consumes the burst allowance and fans out. + cb1 := common.NewCallback() + req1 := jsonrpc.Request[json.RawMessage]{ID: "req-1", Method: MethodCapabilityExec, Params: ¶ms} + require.NoError(t, h.HandleJSONRPCUserMessage(t.Context(), req1, cb1)) + + // Second request is rate limited at ingress and never reaches the DON. + cb2 := common.NewCallback() + req2 := jsonrpc.Request[json.RawMessage]{ID: "req-2", Method: MethodCapabilityExec, Params: ¶ms} + err = h.HandleJSONRPCUserMessage(t.Context(), req2, cb2) + require.Error(t, err) + assert.Contains(t, err.Error(), "rate limit") + + don.AssertNumberOfCalls(t, "SendToNode", 1) +} + func TestConfidentialRelayHandler_LateNodeResponse(t *testing.T) { t.Parallel() h, cb, _, _ := setupHandler(t, 4)