From 9466235f283a419a658e653b9d339bd913fb6be1 Mon Sep 17 00:00:00 2001 From: Cedric Date: Thu, 11 Jun 2026 11:29:31 +0100 Subject: [PATCH 1/2] CRE-4857: Small mtls improvements (#22787) - Add a concurrency limiter for mtls requests - Fail requests with invalid credentials before acquiring a slot --- .../handlers/capabilities/v2/http_handler.go | 33 ++++- .../capabilities/v2/http_handler_test.go | 75 ++++++++++ core/services/gateway/network/httpclient.go | 49 +++++- .../gateway/network/httpclient_mtls_test.go | 140 ++++++++++++++++-- 4 files changed, 277 insertions(+), 20 deletions(-) diff --git a/core/services/gateway/handlers/capabilities/v2/http_handler.go b/core/services/gateway/handlers/capabilities/v2/http_handler.go index d8465aeadca..f2fc9dd87ba 100644 --- a/core/services/gateway/handlers/capabilities/v2/http_handler.go +++ b/core/services/gateway/handlers/capabilities/v2/http_handler.go @@ -50,6 +50,7 @@ type gatewayHandler struct { globalNodeRateLimiter limits.RateLimiter // Global rate limiter shared across all incoming node requests from workflow DON perNodeRateLimiters map[string]limits.RateLimiter // Per-node rate limiters keyed by node address, one independent bucket per DON member mtlsRequestRateLimiter limits.RateLimiter + mtlsConcurrencyLimiter limits.ResourcePoolLimiter[int] // Bounds the number of in-flight outbound mTLS requests wg sync.WaitGroup stopCh services.StopChan responseCache ResponseCache // Caches HTTP responses to avoid redundant requests for outbound HTTP actions @@ -141,6 +142,11 @@ func NewGatewayHandler(handlerConfig json.RawMessage, donConfig *config.DONConfi return nil, fmt.Errorf("failed to create mtls rate limiter: %w", err) } + mtlsConcurrencyLimiter, err := limits.MakeResourcePoolLimiter(lf, cresettings.Default.GatewayHTTPActionMtlsConcurrencyLimit) + if err != nil { + return nil, fmt.Errorf("failed to create mtls concurrency limiter: %w", err) + } + metrics, err := metrics.NewMetrics(donConfig) if err != nil { return nil, fmt.Errorf("failed to initialize metrics: %w", err) @@ -156,6 +162,7 @@ func NewGatewayHandler(handlerConfig json.RawMessage, donConfig *config.DONConfi globalNodeRateLimiter: globalNodeRateLimiter, perNodeRateLimiters: perNodeRateLimiters, mtlsRequestRateLimiter: mtlsRequestRateLimiter, + mtlsConcurrencyLimiter: mtlsConcurrencyLimiter, stopCh: make(services.StopChan), responseCache: newResponseCache(lggr, cfg.OutboundRequestCacheTTLMs, metrics), triggerHandler: triggerHandler, @@ -272,13 +279,6 @@ func (h *gatewayHandler) send(ctx context.Context, httpReq network.HTTPRequest, return h.httpClient.Send(ctx, httpReq) } - // We don't have access to the org here, so this will fall back to the environment default (=false). - // That's appropriate because all fields set on the request come from untrusted nodes. - // The capability separately applies an org-specific check. - if !h.mtlsRequestRateLimiter.Allow(ctx) { - return nil, fmt.Errorf("global mtls request rate limit exceeded: %w", network.ErrBlockedRequest) - } - if h.httpClientFactory == nil { return nil, errors.New("nil http client factory, cannot make mtls request") } @@ -290,16 +290,29 @@ func (h *gatewayHandler) send(ctx context.Context, httpReq network.HTTPRequest, // b) we apply rate limits limiting the ability of sending nodes to spam requests // c) we apply per-owner rate limits in the action capability in the // workflow node limiting the ability of users to abuse this flow by spamming Mtls requests. + // The client enforces the mtls concurrency limit internally (on the request's + // capped-timeout context) before delegating to the underlying transport. client, err := h.httpClientFactory(network.HTTPClientConfig{ Mtls: &gateway_common.MtlsAuth{ PrivateKey: req.Mtls.PrivateKey, Certificate: req.Mtls.Certificate, }, + ConcurrencyLimiter: h.mtlsConcurrencyLimiter, }) if err != nil { return nil, fmt.Errorf("failed to instantiate http client for mtls request: %w", err) } + // We don't have access to the org here, so this will fall back to the environment default (=false). + // That's appropriate because all fields set on the request come from untrusted nodes. + // The capability separately applies an org-specific check. + + // Note: we intentionally consume the rate-limit after instantiating the client so that a malicious user + // can't send requests with invalid mtls credentials and thus cheaply consume global tokens. + if !h.mtlsRequestRateLimiter.Allow(ctx) { + return nil, fmt.Errorf("global mtls request rate limit exceeded: %w", network.ErrBlockedRequest) + } + return client.Send(ctx, httpReq) } @@ -478,6 +491,12 @@ func (h *gatewayHandler) Close() error { h.lggr.Errorw("failed to close per-node rate limiter", "nodeAddr", nodeAddr, "err", err) } } + if err = h.mtlsRequestRateLimiter.Close(); err != nil { + h.lggr.Errorw("failed to close mtls request rate limiter", "err", err) + } + if err = h.mtlsConcurrencyLimiter.Close(); err != nil { + h.lggr.Errorw("failed to close mtls concurrency limiter", "err", err) + } close(h.stopCh) h.wg.Wait() return nil diff --git a/core/services/gateway/handlers/capabilities/v2/http_handler_test.go b/core/services/gateway/handlers/capabilities/v2/http_handler_test.go index 0c72c604612..9ff433558ed 100644 --- a/core/services/gateway/handlers/capabilities/v2/http_handler_test.go +++ b/core/services/gateway/handlers/capabilities/v2/http_handler_test.go @@ -947,6 +947,9 @@ func TestGatewayHandler_Send_NoMtls_UsesDefaultClient(t *testing.T) { func TestGatewayHandler_Send_MtlsBlockedByRateLimit(t *testing.T) { handler := createTestHandler(t) handler.mtlsRequestRateLimiter = limits.GlobalRateLimiter(0, 0) + handler.httpClientFactory = func(config network.HTTPClientConfig) (network.HTTPClient, error) { + return httpmocks.NewHTTPClient(t), nil + } httpReq := network.HTTPRequest{Method: "GET", URL: "https://example.com/api"} outboundReq := gateway_common.OutboundHTTPRequest{ @@ -965,6 +968,41 @@ func TestGatewayHandler_Send_MtlsBlockedByRateLimit(t *testing.T) { require.Contains(t, err.Error(), "global mtls request rate limit exceeded") } +// TestGatewayHandler_Send_MtlsPassesConcurrencyLimiterToFactory verifies the +// handler hands its shared mtls concurrency limiter to the client factory. The +// limiter is enforced inside the HTTP client (on the request's capped-timeout +// context); that enforcement is covered by the network package tests. +func TestGatewayHandler_Send_MtlsPassesConcurrencyLimiterToFactory(t *testing.T) { + handler := createTestHandler(t) + handler.mtlsRequestRateLimiter = limits.GlobalRateLimiter(100, 100) + + httpReq := network.HTTPRequest{Method: "GET", URL: "https://example.com/api"} + outboundReq := gateway_common.OutboundHTTPRequest{ + Method: "GET", + URL: "https://example.com/api", + Mtls: &gateway_common.MtlsAuth{ + PrivateKey: []byte("private-key"), + Certificate: []byte("certificate"), + }, + } + + expectedResp := &network.HTTPResponse{StatusCode: 200, Body: []byte("ok")} + mtlsClient := httpmocks.NewHTTPClient(t) + mtlsClient.EXPECT().Send(mock.Anything, httpReq).Return(expectedResp, nil).Once() + + var capturedConfig network.HTTPClientConfig + handler.httpClientFactory = func(config network.HTTPClientConfig) (network.HTTPClient, error) { + capturedConfig = config + return mtlsClient, nil + } + + resp, err := handler.send(testutils.Context(t), httpReq, outboundReq) + require.NoError(t, err) + require.Equal(t, expectedResp, resp) + require.NotNil(t, capturedConfig.ConcurrencyLimiter, "handler must pass its mtls concurrency limiter to the client factory") + require.Equal(t, handler.mtlsConcurrencyLimiter, capturedConfig.ConcurrencyLimiter) +} + func TestGatewayHandler_Send_MtlsUsesFactory(t *testing.T) { handler := createTestHandler(t) handler.mtlsRequestRateLimiter = limits.GlobalRateLimiter(100, 100) @@ -1026,6 +1064,37 @@ func TestGatewayHandler_Send_MtlsFactoryError(t *testing.T) { require.ErrorIs(t, err, factoryErr, "factory error should be wrapped and discoverable via errors.Is") } +// TestGatewayHandler_Send_InvalidMtlsCertDoesNotConsumeGlobalTokens verifies that a +// request carrying invalid mTLS credentials does not consume a global rate-limit token. +// Otherwise a malicious user could cheaply drain the shared mtls token bucket by spamming +// requests with bogus certificates. It uses the real HTTP client factory so that the +// production code path is what rejects the certificate as invalid. +func TestGatewayHandler_Send_InvalidMtlsCertDoesNotConsumeGlobalTokens(t *testing.T) { + handler := createTestHandler(t) + // Burst of exactly 1: only a single mtls request may pass the rate limiter. + handler.mtlsRequestRateLimiter = limits.GlobalRateLimiter(1, 1) + handler.httpClientFactory = network.NewHTTPClientFactory(network.HTTPClientConfig{}, logger.Test(t)) + + httpReq := network.HTTPRequest{Method: "GET", URL: "https://example.com/api"} + outboundReq := gateway_common.OutboundHTTPRequest{ + Method: "GET", + URL: "https://example.com/api", + Mtls: &gateway_common.MtlsAuth{PrivateKey: []byte("not-a-key"), Certificate: []byte("not-a-cert")}, + } + + ctx := testutils.Context(t) + resp, err := handler.send(ctx, httpReq, outboundReq) + require.Error(t, err) + require.Nil(t, resp) + require.Contains(t, err.Error(), "failed to parse MtlsAuth into KeyPair", + "the real client factory should reject the invalid certificate material") + + // The single available token must still be present: the failed request above must not + // have consumed it. + require.True(t, handler.mtlsRequestRateLimiter.Allow(ctx), + "global mtls rate-limit token must not be consumed by a request with an invalid certificate") +} + // TestGatewayHandler_Send_MtlsRoutesThroughCallbackOnly_DefaultClientUntouched // verifies that an mTLS request flowing through the full callback path does not // touch the default (shared) http client even when the factory returns a working @@ -1062,6 +1131,9 @@ func TestGatewayHandler_Send_MtlsRoutesThroughCallbackOnly_DefaultClientUntouche func TestGatewayHandler_Send_MtlsBlockedRequestIsValidationError(t *testing.T) { handler := createTestHandler(t) handler.mtlsRequestRateLimiter = limits.GlobalRateLimiter(0, 0) + handler.httpClientFactory = func(config network.HTTPClientConfig) (network.HTTPClient, error) { + return httpmocks.NewHTTPClient(t), nil + } httpReq := network.HTTPRequest{Method: "GET", URL: "https://example.com/api", Timeout: 5 * time.Second} outboundReq := gateway_common.OutboundHTTPRequest{ @@ -1085,6 +1157,9 @@ func TestGatewayHandler_Send_MtlsBlockedRequestIsValidationError(t *testing.T) { // meaning mtls is blocked out of the box. func TestGatewayHandler_Send_MtlsRateLimitEnabledByDefault(t *testing.T) { handler := createTestHandler(t) + handler.httpClientFactory = func(config network.HTTPClientConfig) (network.HTTPClient, error) { + return httpmocks.NewHTTPClient(t), nil + } httpReq := network.HTTPRequest{Method: "GET", URL: "https://example.com/api"} outboundReq := gateway_common.OutboundHTTPRequest{ diff --git a/core/services/gateway/network/httpclient.go b/core/services/gateway/network/httpclient.go index 1d74c5709a3..ea9e6295fa9 100644 --- a/core/services/gateway/network/httpclient.go +++ b/core/services/gateway/network/httpclient.go @@ -20,6 +20,7 @@ import ( "github.com/doyensec/safeurl" "github.com/smartcontractkit/chainlink-common/pkg/logger" + "github.com/smartcontractkit/chainlink-common/pkg/settings/limits" "github.com/smartcontractkit/chainlink-common/pkg/types/gateway" "github.com/smartcontractkit/chainlink/v2/core/utils" ) @@ -51,6 +52,11 @@ type HTTPClientConfig struct { // Mtls, when set, configures the client to present the supplied client // certificate for mutual TLS. Mtls *gateway.MtlsAuth + + // ConcurrencyLimiter, when set together with Mtls, bounds the number of + // in-flight mTLS requests. The limiter is acquired on the request's + // (capped) context so waiters self-evict at the request timeout. + ConcurrencyLimiter limits.ResourcePoolLimiter[int] } // merge returns a copy of c with any set fields from override applied on top. @@ -98,6 +104,9 @@ func (c HTTPClientConfig) merge(override HTTPClientConfig) HTTPClientConfig { if override.Mtls != nil { merged.Mtls = override.Mtls } + if override.ConcurrencyLimiter != nil { + merged.ConcurrencyLimiter = override.ConcurrencyLimiter + } return merged } @@ -223,8 +232,32 @@ func responseHeadersFromNetHeader(h http.Header) (map[string]string, map[string] return headers, multiHeaders } +// httpDoer is the subset of the HTTP client used by httpClient. It is satisfied +// by *safeurl.WrappedClient and by concurrencyLimitedClient, which decorates it. +type httpDoer interface { + Do(req *http.Request) (*http.Response, error) +} + +// concurrencyLimitedClient bounds the number of in-flight requests delegated to +// the underlying client. The slot is acquired on the request's context, so a +// waiter self-evicts when that context (carrying the capped request timeout) +// expires rather than blocking indefinitely. +type concurrencyLimitedClient struct { + client httpDoer + limiter limits.ResourcePoolLimiter[int] +} + +func (c *concurrencyLimitedClient) Do(req *http.Request) (*http.Response, error) { + free, err := c.limiter.Wait(req.Context(), 1) + if err != nil { + return nil, fmt.Errorf("mtls concurrency limit exceeded: %w", ErrBlockedRequest) + } + defer free() + return c.client.Do(req) +} + type httpClient struct { - client *safeurl.WrappedClient + client httpDoer config HTTPClientConfig lggr logger.Logger metrics *httpClientMetrics @@ -259,6 +292,8 @@ func NewHTTPClient(config HTTPClientConfig, lggr logger.Logger) (HTTPClient, err SetCheckRedirect(disableRedirects). SetTransport(defaultTransport) + var client httpDoer + if config.Mtls != nil { // Defence-in-depth protection against accidental reuse // of the HTTP client leading to auth'd connections leaking across @@ -276,6 +311,16 @@ func NewHTTPClient(config HTTPClientConfig, lggr logger.Logger) (HTTPClient, err MinVersion: tls.VersionTLS12, } safeConfigBuilder.SetTransport(defaultTransport) + + if config.ConcurrencyLimiter == nil { + return nil, errors.New("mtls requires a ConcurrencyLimiter") + } + client = &concurrencyLimitedClient{ + client: safeurl.Client(safeConfigBuilder.Build()), + limiter: config.ConcurrencyLimiter, + } + } else { + client = safeurl.Client(safeConfigBuilder.Build()) } metrics, err := newHTTPClientMetrics() @@ -285,7 +330,7 @@ func NewHTTPClient(config HTTPClientConfig, lggr logger.Logger) (HTTPClient, err return &httpClient{ config: config, - client: safeurl.Client(safeConfigBuilder.Build()), + client: client, lggr: lggr, metrics: metrics, }, nil diff --git a/core/services/gateway/network/httpclient_mtls_test.go b/core/services/gateway/network/httpclient_mtls_test.go index 46b57b26e96..1d8f44ed8ab 100644 --- a/core/services/gateway/network/httpclient_mtls_test.go +++ b/core/services/gateway/network/httpclient_mtls_test.go @@ -1,6 +1,7 @@ package network import ( + "context" "crypto/ecdsa" "crypto/elliptic" "crypto/rand" @@ -17,12 +18,43 @@ import ( "testing" "time" + "github.com/doyensec/safeurl" "github.com/stretchr/testify/require" "github.com/smartcontractkit/chainlink-common/pkg/logger" + "github.com/smartcontractkit/chainlink-common/pkg/settings/limits" "github.com/smartcontractkit/chainlink-common/pkg/types/gateway" ) +// testMtlsLimiter returns a generously-sized concurrency limiter for tests that +// only need mTLS construction to succeed (the limit itself is not exercised). +func testMtlsLimiter() limits.ResourcePoolLimiter[int] { + return limits.GlobalResourcePoolLimiter[int](50) +} + +// doerFunc adapts a function to the httpDoer interface. +type doerFunc func(*http.Request) (*http.Response, error) + +func (f doerFunc) Do(r *http.Request) (*http.Response, error) { return f(r) } + +// underlyingSafeURLClient unwraps the *httpClient's doer down to the safeurl +// client, transparently stepping through the concurrencyLimitedClient decorator +// that the mTLS path installs. +func underlyingSafeURLClient(t *testing.T, hc *httpClient) *safeurl.WrappedClient { + t.Helper() + switch c := hc.client.(type) { + case *safeurl.WrappedClient: + return c + case *concurrencyLimitedClient: + wc, ok := c.client.(*safeurl.WrappedClient) + require.True(t, ok, "expected *safeurl.WrappedClient under the limiter, got %T", c.client) + return wc + default: + t.Fatalf("unexpected client type %T", hc.client) + return nil + } +} + // pemKeyPair generates a fresh ECDSA P-256 key and a self-signed certificate for // the given common name. Returns PEM-encoded certificate and private key bytes // in the same form `tls.X509KeyPair` accepts. @@ -102,7 +134,8 @@ func TestNewHTTPClientWithOptions_MtlsValidKeyPair(t *testing.T) { certPEM, keyPEM, _ := pemKeyPair(t, "client") client, err := NewHTTPClient(HTTPClientConfig{ - Mtls: &gateway.MtlsAuth{Certificate: certPEM, PrivateKey: keyPEM}, + Mtls: &gateway.MtlsAuth{Certificate: certPEM, PrivateKey: keyPEM}, + ConcurrencyLimiter: testMtlsLimiter(), }, lggr) require.NoError(t, err) require.NotNil(t, client) @@ -123,7 +156,8 @@ func TestNewHTTPClientFactory_MtlsFlow(t *testing.T) { t.Run("valid mtls config returns a working client", func(t *testing.T) { certPEM, keyPEM, _ := pemKeyPair(t, "client") client, err := factory(HTTPClientConfig{ - Mtls: &gateway.MtlsAuth{Certificate: certPEM, PrivateKey: keyPEM}, + Mtls: &gateway.MtlsAuth{Certificate: certPEM, PrivateKey: keyPEM}, + ConcurrencyLimiter: testMtlsLimiter(), }) require.NoError(t, err) require.NotNil(t, client) @@ -189,9 +223,10 @@ func TestHTTPClient_MtlsPresentsCertificateToServer(t *testing.T) { client, err := NewHTTPClient( HTTPClientConfig{ - AllowedIPs: []string{host}, - AllowedPorts: []int{port}, - Mtls: &gateway.MtlsAuth{Certificate: clientCertPEM, PrivateKey: clientKeyPEM}, + AllowedIPs: []string{host}, + AllowedPorts: []int{port}, + Mtls: &gateway.MtlsAuth{Certificate: clientCertPEM, PrivateKey: clientKeyPEM}, + ConcurrencyLimiter: testMtlsLimiter(), }, lggr, ) @@ -199,7 +234,7 @@ func TestHTTPClient_MtlsPresentsCertificateToServer(t *testing.T) { hc, ok := client.(*httpClient) require.True(t, ok) - transport, ok := hc.client.Client.Transport.(*http.Transport) + transport, ok := underlyingSafeURLClient(t, hc).Client.Transport.(*http.Transport) require.True(t, ok) require.NotNil(t, transport.TLSClientConfig) transport.TLSClientConfig.RootCAs = serverPool @@ -259,7 +294,7 @@ func TestHTTPClient_NoMtls_RejectedByMtlsServer(t *testing.T) { // Trust the server cert so we get past server-cert verification — the TLS // failure should come from the missing *client* cert. hc := client.(*httpClient) - transport := hc.client.Client.Transport.(*http.Transport) + transport := underlyingSafeURLClient(t, hc).Client.Transport.(*http.Transport) if transport.TLSClientConfig == nil { transport.TLSClientConfig = &tls.Config{MinVersion: tls.VersionTLS12} } @@ -285,19 +320,102 @@ func TestHTTPClient_MtlsDisablesKeepAlives(t *testing.T) { certPEM, keyPEM, _ := pemKeyPair(t, "client") client, err := NewHTTPClient(HTTPClientConfig{ - Mtls: &gateway.MtlsAuth{Certificate: certPEM, PrivateKey: keyPEM}, + Mtls: &gateway.MtlsAuth{Certificate: certPEM, PrivateKey: keyPEM}, + ConcurrencyLimiter: testMtlsLimiter(), }, lggr) require.NoError(t, err) hc, ok := client.(*httpClient) require.True(t, ok) - require.NotNil(t, hc.client.Client) - transport, ok := hc.client.Client.Transport.(*http.Transport) - require.True(t, ok, "expected *http.Transport, got %T", hc.client.Client.Transport) + sc := underlyingSafeURLClient(t, hc) + require.NotNil(t, sc.Client) + transport, ok := sc.Client.Transport.(*http.Transport) + require.True(t, ok, "expected *http.Transport, got %T", sc.Client.Transport) require.True(t, transport.DisableKeepAlives, "keep-alives must be disabled to prevent auth'd connection reuse across users") require.Equal(t, 10*time.Second, transport.TLSHandshakeTimeout, "TLS handshake timeout should be set (safeurl defaults to 0 == no timeout)") require.NotNil(t, transport.TLSClientConfig) require.Len(t, transport.TLSClientConfig.Certificates, 1, "client certificate must be installed on the transport") } + +func TestNewHTTPClient_MtlsRequiresConcurrencyLimiter(t *testing.T) { + t.Parallel() + certPEM, keyPEM, _ := pemKeyPair(t, "client") + + _, err := NewHTTPClient(HTTPClientConfig{ + Mtls: &gateway.MtlsAuth{Certificate: certPEM, PrivateKey: keyPEM}, + }, logger.Test(t)) + require.Error(t, err) + require.Contains(t, err.Error(), "mtls requires a ConcurrencyLimiter") +} + +// TestConcurrencyLimitedClient_BlocksWhenSaturated verifies a held slot blocks a +// second request until its context expires, and that completing the first frees +// the slot. +func TestConcurrencyLimitedClient_BlocksWhenSaturated(t *testing.T) { + t.Parallel() + + entered := make(chan struct{}) + release := make(chan struct{}) + wrapped := &concurrencyLimitedClient{ + client: doerFunc(func(*http.Request) (*http.Response, error) { + entered <- struct{}{} + <-release + return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}, nil + }), + limiter: limits.GlobalResourcePoolLimiter[int](1), + } + + req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, "https://example.com", nil) + require.NoError(t, err) + + // First request takes the only slot and blocks inside Do. + done := make(chan struct{}) + go func() { defer close(done); _, _ = wrapped.Do(req) }() + <-entered + + avail, err := wrapped.limiter.Available(context.Background()) + require.NoError(t, err) + require.Equal(t, 0, avail, "the single slot should be held") + + // Second request is denied once its (short) context expires. + ctx2, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond) + defer cancel() + _, err = wrapped.Do(req.Clone(ctx2)) + require.ErrorIs(t, err, ErrBlockedRequest) + require.Contains(t, err.Error(), "mtls concurrency limit exceeded") + + // Releasing the first request frees the slot. + close(release) + <-done + avail, err = wrapped.limiter.Available(context.Background()) + require.NoError(t, err) + require.Equal(t, 1, avail, "slot must be freed after the request completes") +} + +// TestHTTPClient_MtlsConcurrencyLimit_RespectsRequestTimeout proves the wait is +// bounded by the (capped) request timeout rather than blocking indefinitely: +// with a zero-capacity pool no slot can ever be acquired, so Send must return +// the concurrency error around the request timeout. +func TestHTTPClient_MtlsConcurrencyLimit_RespectsRequestTimeout(t *testing.T) { + t.Parallel() + lggr := logger.Test(t) + certPEM, keyPEM, _ := pemKeyPair(t, "client") + + client, err := NewHTTPClient(HTTPClientConfig{ + Mtls: &gateway.MtlsAuth{Certificate: certPEM, PrivateKey: keyPEM}, + ConcurrencyLimiter: limits.GlobalResourcePoolLimiter[int](0), + }, lggr) + require.NoError(t, err) + + start := time.Now() + _, err = client.Send(t.Context(), HTTPRequest{ + Method: http.MethodGet, + URL: "https://example.com", + Timeout: 100 * time.Millisecond, + }) + require.ErrorIs(t, err, ErrBlockedRequest) + require.Contains(t, err.Error(), "mtls concurrency limit exceeded") + require.Less(t, time.Since(start), time.Second, "wait must be bounded by the request timeout") +} From ecb9b97e0a646c059d8aaba8acf36a625cd1c553 Mon Sep 17 00:00:00 2001 From: Gabriel Paradiso Date: Thu, 11 Jun 2026 12:56:24 +0200 Subject: [PATCH 2/2] chore: bump deps and remove replace (#22803) --- core/scripts/go.mod | 2 +- deployment/go.mod | 2 +- go.mod | 5 +---- go.sum | 4 ++-- integration-tests/go.mod | 2 +- integration-tests/load/go.mod | 2 +- system-tests/lib/go.mod | 2 +- system-tests/tests/go.mod | 2 +- 8 files changed, 9 insertions(+), 12 deletions(-) diff --git a/core/scripts/go.mod b/core/scripts/go.mod index febedc896a6..ae0c4919e93 100644 --- a/core/scripts/go.mod +++ b/core/scripts/go.mod @@ -221,7 +221,7 @@ require ( github.com/docker/go-connections v0.7.0 // indirect github.com/docker/go-units v0.5.0 // indirect github.com/dominikbraun/graph v0.23.0 // indirect - github.com/doyensec/safeurl v0.2.3 // indirect + github.com/doyensec/safeurl v0.2.5 // indirect github.com/dustin/go-humanize v1.0.1 // indirect github.com/dvsekhvalnov/jose2go v1.7.0 // indirect github.com/ebitengine/purego v0.10.0 // indirect diff --git a/deployment/go.mod b/deployment/go.mod index ad3b09bce1c..2068856fbe2 100644 --- a/deployment/go.mod +++ b/deployment/go.mod @@ -212,7 +212,7 @@ require ( github.com/docker/go-connections v0.6.0 // indirect github.com/docker/go-units v0.5.0 // indirect github.com/dominikbraun/graph v0.23.0 // indirect - github.com/doyensec/safeurl v0.2.3 // indirect + github.com/doyensec/safeurl v0.2.5 // indirect github.com/dustin/go-humanize v1.0.1 // indirect github.com/dvsekhvalnov/jose2go v1.7.0 // indirect github.com/ebitengine/purego v0.10.0 // indirect diff --git a/go.mod b/go.mod index cd617c542c6..c9de4973453 100644 --- a/go.mod +++ b/go.mod @@ -18,7 +18,7 @@ require ( github.com/deckarep/golang-set/v2 v2.9.0 github.com/docker/go-connections v0.6.0 github.com/dominikbraun/graph v0.23.0 - github.com/doyensec/safeurl v0.2.3 + github.com/doyensec/safeurl v0.2.5 github.com/ethereum/go-ethereum v1.17.3 github.com/fatih/color v1.19.0 github.com/fxamacker/cbor/v2 v2.9.2 @@ -430,9 +430,6 @@ require ( replace github.com/fbsobreira/gotron-sdk => github.com/smartcontractkit/chainlink-tron/relayer/gotron-sdk v0.0.5-0.20260218133534-cbd44da2856b -// to be removed after https://github.com/doyensec/safeurl/pull/13 is merged -replace github.com/doyensec/safeurl => github.com/cedric-cordenier/safeurl v0.0.0-20260609130317-eddfd657b6e3 - tool github.com/smartcontractkit/chainlink-common/pkg/loop/cmd/loopinstall tool github.com/smartcontractkit/chainlink-common/script/cmd/dependabot diff --git a/go.sum b/go.sum index 576536955e2..a1f8dbba912 100644 --- a/go.sum +++ b/go.sum @@ -205,8 +205,6 @@ github.com/bytedance/sonic v1.12.3/go.mod h1:B8Gt/XvtZ3Fqj+iSKMypzymZxw/FVwgIGKz github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU= github.com/bytedance/sonic/loader v0.2.0 h1:zNprn+lsIP06C/IqCHs3gPQIvnvpKbbxyXQP1iU4kWM= github.com/bytedance/sonic/loader v0.2.0/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU= -github.com/cedric-cordenier/safeurl v0.0.0-20260609130317-eddfd657b6e3 h1:ID0FEE196M/l4d9M8/Ayk/DspFG1D0B4YJe85rzdWoM= -github.com/cedric-cordenier/safeurl v0.0.0-20260609130317-eddfd657b6e3/go.mod h1:3H0cgRpPYPSpgxRRn5yGD35Ns/LgGX/BVWSBbzUqXtY= github.com/cenkalti/backoff v2.2.1+incompatible h1:tNowT99t7UNflLxfYYSlKYsBpXdEet03Pg2g16Swow4= github.com/cenkalti/backoff v2.2.1+incompatible/go.mod h1:90ReRw6GdpyfrHakVjL/QHaoyV4aDUVVkXQJJJ3NXXM= github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8= @@ -356,6 +354,8 @@ github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4 github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= github.com/dominikbraun/graph v0.23.0 h1:TdZB4pPqCLFxYhdyMFb1TBdFxp8XLcJfTTBQucVPgCo= github.com/dominikbraun/graph v0.23.0/go.mod h1:yOjYyogZLY1LSG9E33JWZJiq5k83Qy2C6POAuiViluc= +github.com/doyensec/safeurl v0.2.5 h1:kKu0JNQy0tJ8jkDyB5h6Aml9vWWniq+mpoa12EGLcOQ= +github.com/doyensec/safeurl v0.2.5/go.mod h1:3H0cgRpPYPSpgxRRn5yGD35Ns/LgGX/BVWSBbzUqXtY= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/dvsekhvalnov/jose2go v1.7.0 h1:bnQc8+GMnidJZA8zc6lLEAb4xNrIqHwO+9TzqvtQZPo= diff --git a/integration-tests/go.mod b/integration-tests/go.mod index 3d8ff683c80..31328f1d035 100644 --- a/integration-tests/go.mod +++ b/integration-tests/go.mod @@ -181,7 +181,7 @@ require ( github.com/docker/go-connections v0.6.0 // indirect github.com/docker/go-units v0.5.0 // indirect github.com/dominikbraun/graph v0.23.0 // indirect - github.com/doyensec/safeurl v0.2.3 // indirect + github.com/doyensec/safeurl v0.2.5 // indirect github.com/dustin/go-humanize v1.0.1 // indirect github.com/dvsekhvalnov/jose2go v1.7.0 // indirect github.com/ebitengine/purego v0.10.0 // indirect diff --git a/integration-tests/load/go.mod b/integration-tests/load/go.mod index fd4192cf85e..c25cacc43be 100644 --- a/integration-tests/load/go.mod +++ b/integration-tests/load/go.mod @@ -180,7 +180,7 @@ require ( github.com/docker/go-connections v0.6.0 // indirect github.com/docker/go-units v0.5.0 // indirect github.com/dominikbraun/graph v0.23.0 // indirect - github.com/doyensec/safeurl v0.2.3 // indirect + github.com/doyensec/safeurl v0.2.5 // indirect github.com/dustin/go-humanize v1.0.1 // indirect github.com/dvsekhvalnov/jose2go v1.7.0 // indirect github.com/ebitengine/purego v0.10.0 // indirect diff --git a/system-tests/lib/go.mod b/system-tests/lib/go.mod index 019e32268e0..bf1f91f7836 100644 --- a/system-tests/lib/go.mod +++ b/system-tests/lib/go.mod @@ -203,7 +203,7 @@ require ( github.com/docker/go-connections v0.7.0 // indirect github.com/docker/go-units v0.5.0 // indirect github.com/dominikbraun/graph v0.23.0 // indirect - github.com/doyensec/safeurl v0.2.3 // indirect + github.com/doyensec/safeurl v0.2.5 // indirect github.com/dustin/go-humanize v1.0.1 // indirect github.com/dvsekhvalnov/jose2go v1.7.0 // indirect github.com/ebitengine/purego v0.10.0 // indirect diff --git a/system-tests/tests/go.mod b/system-tests/tests/go.mod index 29ab55cf9f4..785984acc41 100644 --- a/system-tests/tests/go.mod +++ b/system-tests/tests/go.mod @@ -301,7 +301,7 @@ require ( github.com/docker/go-connections v0.7.0 // indirect github.com/docker/go-units v0.5.0 // indirect github.com/dominikbraun/graph v0.23.0 // indirect - github.com/doyensec/safeurl v0.2.3 // indirect + github.com/doyensec/safeurl v0.2.5 // indirect github.com/dustin/go-humanize v1.0.1 // indirect github.com/dvsekhvalnov/jose2go v1.7.0 // indirect github.com/ebitengine/purego v0.10.0 // indirect