diff --git a/pkg/networking/http_client.go b/pkg/networking/http_client.go index 733cd48b9e..8a5bd1fa9d 100644 --- a/pkg/networking/http_client.go +++ b/pkg/networking/http_client.go @@ -35,8 +35,11 @@ const HttpsScheme = "https" // HttpScheme is the HTTP scheme const HttpScheme = "http" -// Dialer control function for validating addresses prior to connection -func protectedDialerControl(_, address string, _ syscall.RawConn) error { +// ProtectedDialerControl is a Dialer control function for validating addresses +// prior to connection. It returns an error if the resolved address points at a +// private, loopback, or link-local IP, providing an SSRF guard at dial time. +// Pass it to (&net.Dialer{Control: ...}).DialContext on outbound HTTP transports. +func ProtectedDialerControl(_, address string, _ syscall.RawConn) error { err := AddressReferencesPrivateIp(address) if err != nil { return err @@ -100,9 +103,12 @@ type HttpClientBuilder struct { tlsHandshakeTimeout time.Duration responseHeaderTimeout time.Duration caCertPath string + clientCertPath string + clientKeyPath string authTokenFile string allowPrivate bool insecureAllowHTTP bool + insecureSkipVerify bool disableKeepAlives bool } @@ -121,6 +127,13 @@ func (b *HttpClientBuilder) WithCABundle(path string) *HttpClientBuilder { return b } +// WithClientCert sets a client certificate and key for mutual TLS (mTLS) authentication. +func (b *HttpClientBuilder) WithClientCert(certPath, keyPath string) *HttpClientBuilder { + b.clientCertPath = certPath + b.clientKeyPath = keyPath + return b +} + // WithTokenFromFile sets the auth token file path func (b *HttpClientBuilder) WithTokenFromFile(path string) *HttpClientBuilder { b.authTokenFile = path @@ -140,6 +153,13 @@ func (b *HttpClientBuilder) WithInsecureAllowHTTP(allow bool) *HttpClientBuilder return b } +// WithInsecureSkipVerify disables TLS server certificate verification. +// WARNING: This is insecure and should NEVER be used in production +func (b *HttpClientBuilder) WithInsecureSkipVerify(skip bool) *HttpClientBuilder { + b.insecureSkipVerify = skip + return b +} + // WithDisableKeepAlives disables HTTP keep-alive on the transport. When true, // each request uses a fresh connection, ensuring the per-dial SSRF check fires // on every request rather than being bypassed by a reused connection. @@ -164,7 +184,7 @@ func (b *HttpClientBuilder) Build() (*http.Client, error) { if !b.allowPrivate { transport.DialContext = (&net.Dialer{ - Control: protectedDialerControl, + Control: ProtectedDialerControl, }).DialContext } @@ -187,6 +207,34 @@ func (b *HttpClientBuilder) Build() (*http.Client, error) { transport.TLSClientConfig.RootCAs = caCertPool } + if (b.clientCertPath == "") != (b.clientKeyPath == "") { + return nil, fmt.Errorf("both client certificate and key paths must be set for mTLS") + } + if b.clientCertPath != "" { + cert, err := tls.LoadX509KeyPair(b.clientCertPath, b.clientKeyPath) + if err != nil { + return nil, fmt.Errorf("failed to load client certificate: %w", err) + } + + if transport.TLSClientConfig == nil { + transport.TLSClientConfig = &tls.Config{ + MinVersion: tls.VersionTLS12, + } + } + transport.TLSClientConfig.Certificates = []tls.Certificate{cert} + } + + if b.insecureSkipVerify { + if transport.TLSClientConfig == nil { + transport.TLSClientConfig = &tls.Config{ + MinVersion: tls.VersionTLS12, + } + } + //#nosec G402 -- InsecureSkipVerify is intentionally user-configurable via WithInsecureSkipVerify; + // callers must opt in explicitly. + transport.TLSClientConfig.InsecureSkipVerify = true + } + // Start with validation transport var clientTransport http.RoundTripper = &ValidatingTransport{ Transport: transport, diff --git a/pkg/networking/http_client_test.go b/pkg/networking/http_client_test.go index 5d7867a18b..e6b845b232 100644 --- a/pkg/networking/http_client_test.go +++ b/pkg/networking/http_client_test.go @@ -4,8 +4,15 @@ package networking import ( + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" "crypto/tls" + "crypto/x509" + "crypto/x509/pkix" + "encoding/pem" "io" + "math/big" "net/http" "net/http/httptest" "os" @@ -19,6 +26,35 @@ import ( "golang.org/x/oauth2" ) +// generateTestClientCert creates a self-signed certificate/key pair in PEM +// format for testing mTLS client certificate loading. +func generateTestClientCert(t *testing.T) (certPEM, keyPEM []byte) { + t.Helper() + + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + require.NoError(t, err) + + template := &x509.Certificate{ + SerialNumber: big.NewInt(1), + Subject: pkix.Name{CommonName: "test-client"}, + NotBefore: time.Now().Add(-time.Hour), + NotAfter: time.Now().Add(time.Hour), + KeyUsage: x509.KeyUsageDigitalSignature, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth}, + } + + certDER, err := x509.CreateCertificate(rand.Reader, template, template, &key.PublicKey, key) + require.NoError(t, err) + + certPEM = pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: certDER}) + + keyDER, err := x509.MarshalECPrivateKey(key) + require.NoError(t, err) + keyPEM = pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: keyDER}) + + return certPEM, keyPEM +} + func TestNewHttpClientBuilder(t *testing.T) { t.Parallel() @@ -44,6 +80,43 @@ func TestHttpClientBuilder_WithCABundle(t *testing.T) { assert.Equal(t, path, builder.caCertPath) } +func TestHttpClientBuilder_WithClientCert(t *testing.T) { + t.Parallel() + + builder := NewHttpClientBuilder() + certPath, keyPath := "/path/to/client.crt", "/path/to/client.key" + + result := builder.WithClientCert(certPath, keyPath) + + assert.Same(t, builder, result) // fluent interface + assert.Equal(t, certPath, builder.clientCertPath) + assert.Equal(t, keyPath, builder.clientKeyPath) +} + +func TestHttpClientBuilder_WithInsecureSkipVerify(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + skip bool + }{ + {name: "skip verification", skip: true}, + {name: "verify normally", skip: false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + builder := NewHttpClientBuilder() + result := builder.WithInsecureSkipVerify(tt.skip) + + assert.Same(t, builder, result) // fluent interface + assert.Equal(t, tt.skip, builder.insecureSkipVerify) + }) + } +} + func TestHttpClientBuilder_WithTokenFromFile(t *testing.T) { t.Parallel() @@ -248,6 +321,23 @@ lT/G27CBRUlDiDhthwY1dccTCFhICg6ENUGqh2I= assert.NotNil(t, httpTransport.DialContext) }, }, + { + name: "insecure skip verify", + setupBuilder: func() *HttpClientBuilder { + return NewHttpClientBuilder().WithInsecureSkipVerify(true) + }, + setupFiles: func(_ *testing.T) (string, string) { + return "", "" + }, + expectError: false, + validateClient: func(t *testing.T, client *http.Client) { + t.Helper() + transport := client.Transport.(*ValidatingTransport) + httpTransport := transport.Transport.(*http.Transport) + require.NotNil(t, httpTransport.TLSClientConfig) + assert.True(t, httpTransport.TLSClientConfig.InsecureSkipVerify) + }, + }, { name: "invalid CA certificate file", setupBuilder: func() *HttpClientBuilder { @@ -347,6 +437,93 @@ lT/G27CBRUlDiDhthwY1dccTCFhICg6ENUGqh2I= } } +func TestHttpClientBuilder_Build_ClientCert(t *testing.T) { + t.Parallel() + + validCertPEM, validKeyPEM := generateTestClientCert(t) + + writeFile := func(t *testing.T, name string, data []byte) string { + t.Helper() + path := filepath.Join(t.TempDir(), name) + require.NoError(t, os.WriteFile(path, data, 0600)) + return path + } + + tests := []struct { + name string + setupBuilder func(t *testing.T) *HttpClientBuilder + expectError bool + errorContains string + validateCert bool + }{ + { + name: "valid client certificate and key", + setupBuilder: func(t *testing.T) *HttpClientBuilder { + t.Helper() + certPath := writeFile(t, "client.crt", validCertPEM) + keyPath := writeFile(t, "client.key", validKeyPEM) + return NewHttpClientBuilder().WithClientCert(certPath, keyPath) + }, + validateCert: true, + }, + { + name: "certificate without matching key", + setupBuilder: func(t *testing.T) *HttpClientBuilder { + t.Helper() + certPath := writeFile(t, "client.crt", validCertPEM) + return NewHttpClientBuilder().WithClientCert(certPath, "") + }, + expectError: true, + errorContains: "both client certificate and key paths must be set", + }, + { + name: "key without matching certificate", + setupBuilder: func(t *testing.T) *HttpClientBuilder { + t.Helper() + keyPath := writeFile(t, "client.key", validKeyPEM) + return NewHttpClientBuilder().WithClientCert("", keyPath) + }, + expectError: true, + errorContains: "both client certificate and key paths must be set", + }, + { + name: "invalid certificate content", + setupBuilder: func(t *testing.T) *HttpClientBuilder { + t.Helper() + certPath := writeFile(t, "client.crt", []byte("invalid cert")) + keyPath := writeFile(t, "client.key", []byte("invalid key")) + return NewHttpClientBuilder().WithClientCert(certPath, keyPath) + }, + expectError: true, + errorContains: "failed to load client certificate", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + client, err := tt.setupBuilder(t).Build() + + if tt.expectError { + assert.Error(t, err) + assert.Contains(t, err.Error(), tt.errorContains) + assert.Nil(t, client) + return + } + + require.NoError(t, err) + require.NotNil(t, client) + if tt.validateCert { + transport := client.Transport.(*ValidatingTransport) + httpTransport := transport.Transport.(*http.Transport) + require.NotNil(t, httpTransport.TLSClientConfig) + assert.Len(t, httpTransport.TLSClientConfig.Certificates, 1) + } + }) + } +} + func TestValidatingTransport_RoundTrip(t *testing.T) { t.Parallel() diff --git a/pkg/runner/webhook_integration_test.go b/pkg/runner/webhook_integration_test.go index 6946d30262..44945fc390 100644 --- a/pkg/runner/webhook_integration_test.go +++ b/pkg/runner/webhook_integration_test.go @@ -21,8 +21,13 @@ import ( // TestWebhookMiddlewareChainIntegration tests the full execution of the webhook middleware chain // populated by PopulateMiddlewareConfigs in the runner. +// +//nolint:paralleltest // mutates package-level allowPrivateIPsForTesting flag via SetAllowPrivateIPsForTesting func TestWebhookMiddlewareChainIntegration(t *testing.T) { - t.Parallel() + // The webhook clients built by the middleware factories use the production + // dialer guard, which rejects the 127.0.0.1 httptest servers below as part of + // the SSRF protection. Disable the guard for this test so the dial succeeds. + webhook.SetAllowPrivateIPsForTesting(t) // 1. Set up a mutating webhook server that adds a new argument field mutatingServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { diff --git a/pkg/webhook/client.go b/pkg/webhook/client.go index d79f72185f..a2284ef428 100644 --- a/pkg/webhook/client.go +++ b/pkg/webhook/client.go @@ -6,8 +6,6 @@ package webhook import ( "bytes" "context" - "crypto/tls" - "crypto/x509" "encoding/json" "errors" "fmt" @@ -15,14 +13,27 @@ import ( "log/slog" "net" "net/http" - "os" "path/filepath" "strconv" + "sync/atomic" "time" "github.com/stacklok/toolhive/pkg/networking" ) +// allowPrivateIPsForTesting is a test-only escape hatch for the webhook SSRF +// dial-time guard (networking.ProtectedDialerControl, installed via +// networking.HttpClientBuilder.WithPrivateIPs). When true, buildHTTPClient +// allows dials to private, loopback, and link-local addresses so tests can +// talk to httptest servers, which always bind 127.0.0.1. +// +// It is an atomic.Bool so cross-goroutine writes from tests +// (SetAllowPrivateIPsForTesting / SetAllowPrivateIPsForTestMain) and +// cross-goroutine reads from the production client-build path are race-free, +// even if a future test introduces t.Parallel(). Production callers must not +// set this variable. +var allowPrivateIPsForTesting atomic.Bool + // Client is an HTTP client for calling webhook endpoints. type Client struct { httpClient *http.Client @@ -49,16 +60,13 @@ func NewClient(cfg Config, webhookType Type, hmacSecret []byte) (*Client, error) timeout = DefaultTimeout } - transport, err := buildTransport(cfg.TLSConfig) + httpClient, err := buildHTTPClient(cfg.TLSConfig, timeout) if err != nil { - return nil, fmt.Errorf("failed to build HTTP transport: %w", err) + return nil, fmt.Errorf("failed to build HTTP client: %w", err) } return &Client{ - httpClient: &http.Client{ - Transport: transport, - Timeout: timeout, - }, + httpClient: httpClient, config: cfg, hmacSecret: hmacSecret, webhookType: webhookType, @@ -127,7 +135,10 @@ func (c *Client) doHTTPCall(ctx context.Context, body []byte) ([]byte, error) { httpReq.Header.Set(TimestampHeader, strconv.FormatInt(timestamp, 10)) } - // #nosec G704 -- URL is validated in Config.Validate and we use ValidatingTransport for SSRF protection. + // #nosec G704 -- URL is validated in Config.Validate; the inner transport's + // dialer rejects private/loopback/link-local addresses (SSRF guard), and + // ValidatingTransport additionally enforces HTTPS unless InsecureAllowHTTP + // is set for the configured TLS profile. resp, err := c.httpClient.Do(httpReq) if err != nil { return nil, classifyError(c.config.Name, err) @@ -199,60 +210,30 @@ func (c *Client) hmacSecretForRequest(ctx context.Context) ([]byte, error) { return secret, nil } -// buildTransport creates an http.RoundTripper with the specified TLS configuration, -// always wrapped in ValidatingTransport for SSRF protection. -func buildTransport(tlsCfg *TLSConfig) (http.RoundTripper, error) { - transport := &http.Transport{ - TLSHandshakeTimeout: 10 * time.Second, - ResponseHeaderTimeout: 10 * time.Second, - MaxIdleConns: 100, - MaxIdleConnsPerHost: 10, - IdleConnTimeout: 90 * time.Second, - } - - // allowHTTP is true when InsecureSkipVerify is set, which also covers in-cluster - allowHTTP := tlsCfg != nil && tlsCfg.InsecureSkipVerify +// buildHTTPClient creates an *http.Client for the given webhook TLS configuration +// and timeout. The dial-time SSRF guard, HTTPS enforcement, CA bundle, and mTLS +// wiring are all delegated to networking.HttpClientBuilder so the webhook client +// does not maintain its own copy of this logic. +func buildHTTPClient(tlsCfg *TLSConfig, timeout time.Duration) (*http.Client, error) { + builder := networking.NewHttpClientBuilder(). + WithTimeout(timeout). + WithPrivateIPs(allowPrivateIPsForTesting.Load()) if tlsCfg != nil { - tlsConfig := &tls.Config{ - MinVersion: tls.VersionTLS12, - } - - // Load CA bundle if provided. if tlsCfg.CABundlePath != "" { - caCert, err := os.ReadFile(tlsCfg.CABundlePath) - if err != nil { - return nil, fmt.Errorf("failed to read CA bundle: %w", err) - } - caCertPool := x509.NewCertPool() - if !caCertPool.AppendCertsFromPEM(caCert) { - return nil, fmt.Errorf("failed to parse CA certificate bundle") - } - tlsConfig.RootCAs = caCertPool + builder = builder.WithCABundle(tlsCfg.CABundlePath) } - - // Load client certificate for mTLS if provided. - if tlsCfg.ClientCertPath != "" && tlsCfg.ClientKeyPath != "" { - cert, err := tls.LoadX509KeyPair(tlsCfg.ClientCertPath, tlsCfg.ClientKeyPath) - if err != nil { - return nil, fmt.Errorf("failed to load client certificate: %w", err) - } - tlsConfig.Certificates = []tls.Certificate{cert} + if tlsCfg.ClientCertPath != "" || tlsCfg.ClientKeyPath != "" { + builder = builder.WithClientCert(tlsCfg.ClientCertPath, tlsCfg.ClientKeyPath) } - if tlsCfg.InsecureSkipVerify { - //#nosec G402 -- InsecureSkipVerify is intentionally user-configurable for development/testing only. - tlsConfig.InsecureSkipVerify = true + // InsecureSkipVerify also allows plaintext HTTP (e.g. for in-cluster + // endpoints), mirroring the pre-existing webhook behavior. + builder = builder.WithInsecureSkipVerify(true).WithInsecureAllowHTTP(true) } - - transport.TLSClientConfig = tlsConfig } - // Always wrap in ValidatingTransport for SSRF protection, even without TLS config. - return &networking.ValidatingTransport{ - Transport: transport, - InsecureAllowHTTP: allowHTTP, - }, nil + return builder.Build() } // classifyError examines an HTTP client error and returns an appropriately diff --git a/pkg/webhook/client_test.go b/pkg/webhook/client_test.go index 7af2b87815..76ad395b1d 100644 --- a/pkg/webhook/client_test.go +++ b/pkg/webhook/client_test.go @@ -323,8 +323,9 @@ func TestClientHMACSigningHeaders(t *testing.T) { assert.NotEmpty(t, capturedHeaders.Get(TimestampHeader), "expected %s header", TimestampHeader) } +//nolint:paralleltest // mutates package-level allowPrivateIPsForTesting flag func TestClientRereadsMountedHMACSecret(t *testing.T) { - t.Parallel() + SetAllowPrivateIPsForTesting(t) type signedRequest struct { body []byte @@ -557,7 +558,7 @@ func TestClientRequestContentType(t *testing.T) { assert.Equal(t, "application/json", capturedContentType) } -func TestBuildTransport(t *testing.T) { +func TestBuildHTTPClient(t *testing.T) { t.Parallel() tmpDir := t.TempDir() @@ -624,15 +625,15 @@ func TestBuildTransport(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { t.Parallel() - transport, err := buildTransport(tt.tlsCfg) + client, err := buildHTTPClient(tt.tlsCfg, DefaultTimeout) if tt.expectError { assert.Error(t, err) - assert.Nil(t, transport) + assert.Nil(t, client) } else { assert.NoError(t, err) - assert.NotNil(t, transport) + require.NotNil(t, client) if tt.tlsCfg != nil && tt.tlsCfg.InsecureSkipVerify { - vt, ok := transport.(*networking.ValidatingTransport) + vt, ok := client.Transport.(*networking.ValidatingTransport) require.True(t, ok, "expected *networking.ValidatingTransport") tr, ok := vt.Transport.(*http.Transport) require.True(t, ok, "expected *http.Transport") @@ -643,6 +644,75 @@ func TestBuildTransport(t *testing.T) { } } +// TestClientSSRFGuardBlocksPrivateAddress verifies that the production webhook +// client refuses to dial private, loopback, and link-local addresses (cloud +// metadata IP). The dial-time SSRF guard is the load-bearing protection against +// a tenant pointing MCPWebhookConfig.url at internal services. +// +//nolint:paralleltest // exercises the production package-level dialerControl hook +func TestClientSSRFGuardBlocksPrivateAddress(t *testing.T) { + tests := []struct { + name string + host string + }{ + {name: "loopback IPv4", host: "127.0.0.1"}, + {name: "RFC1918 private", host: "10.0.0.1"}, + {name: "cloud metadata link-local", host: "169.254.169.254"}, + {name: "loopback IPv6", host: "[::1]"}, + {name: "link-local IPv6", host: "[fe80::1]"}, + {name: "ULA IPv6", host: "[fc00::1]"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Use HTTPS scheme so ValidatingTransport does not short-circuit the + // request before the dial. Use a high-numbered port so we never + // actually reach a real listener; the dialer guard fires first. + cfg := Config{ + Name: "ssrf-test", + URL: "https://" + tt.host + ":59999/webhook", + Timeout: 2 * time.Second, + FailurePolicy: FailurePolicyFail, + } + client, err := NewClient(cfg, TypeValidating, nil) + require.NoError(t, err) + + req := &Request{ + Version: APIVersion, + UID: "ssrf-uid", + Timestamp: time.Now(), + } + _, callErr := client.Call(t.Context(), req) + require.Error(t, callErr) + + var netErr *NetworkError + require.True(t, errors.As(callErr, &netErr), + "expected *NetworkError, got %T: %v", callErr, callErr) + assert.Contains(t, callErr.Error(), networking.ErrPrivateIpAddress, + "error should reflect dialer rejection of private/loopback/link-local address") + }) + } +} + +// TestBuildHTTPClientInstallsDialerGuard is a narrow unit check that +// buildHTTPClient installs a non-nil DialContext on the inner *http.Transport. +// The integration coverage in TestClientSSRFGuardBlocksPrivateAddress is the +// load-bearing test; this assertion just ensures the wiring does not silently +// regress to a bare transport with no Control hook. +func TestBuildHTTPClientInstallsDialerGuard(t *testing.T) { + t.Parallel() + + client, err := buildHTTPClient(nil, DefaultTimeout) + require.NoError(t, err) + require.NotNil(t, client) + + vt, ok := client.Transport.(*networking.ValidatingTransport) + require.True(t, ok, "expected *networking.ValidatingTransport, got %T", client.Transport) + inner, ok := vt.Transport.(*http.Transport) + require.True(t, ok, "expected inner *http.Transport, got %T", vt.Transport) + assert.NotNil(t, inner.DialContext, "buildHTTPClient must install a DialContext that runs the SSRF dialer guard") +} + func TestClassifyError(t *testing.T) { t.Parallel() diff --git a/pkg/webhook/dialer_testing.go b/pkg/webhook/dialer_testing.go new file mode 100644 index 0000000000..62efbd3d40 --- /dev/null +++ b/pkg/webhook/dialer_testing.go @@ -0,0 +1,37 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package webhook + +import "testing" + +// The functions in this file are test-only helpers that intentionally live in a +// non-_test.go file so that sub-package tests (e.g. pkg/webhook/validating, +// pkg/webhook/mutating) can call into them via TestMain. There is no clean +// alternative for cross-package test-time injection of the package-level +// allowPrivateIPsForTesting flag, so these helpers are exported. The testing.TB +// argument (or the explicit "ForTestMain" suffix) is the signal that the call +// is test-scoped; production code MUST NOT call any of them. + +// SetAllowPrivateIPsForTesting disables the webhook SSRF dial-time guard for +// the duration of tb. It is the sanctioned way for tests to let webhook +// clients dial httptest servers, which always bind 127.0.0.1. The previous +// value is restored via t.Cleanup. +// +// Production code MUST NOT call this function. The testing.TB argument is the +// signal that the call is test-scoped. +func SetAllowPrivateIPsForTesting(tb testing.TB) { + tb.Helper() + prev := allowPrivateIPsForTesting.Swap(true) + tb.Cleanup(func() { allowPrivateIPsForTesting.Store(prev) }) +} + +// SetAllowPrivateIPsForTestMain disables the webhook SSRF dial-time guard for +// the rest of the test binary's lifetime. Use this in TestMain in sub-packages +// whose entire test suite legitimately dials httptest servers bound to +// 127.0.0.1. There is no restore — the binary exits anyway. +// +// Production code MUST NOT call this function. +func SetAllowPrivateIPsForTestMain() { + allowPrivateIPsForTesting.Store(true) +} diff --git a/pkg/webhook/mutating/main_test.go b/pkg/webhook/mutating/main_test.go new file mode 100644 index 0000000000..e7d8098a00 --- /dev/null +++ b/pkg/webhook/mutating/main_test.go @@ -0,0 +1,20 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package mutating + +import ( + "os" + "testing" + + "github.com/stacklok/toolhive/pkg/webhook" +) + +// TestMain disables the webhook SSRF dial-time guard for the entire test +// binary so that webhook clients can dial httptest servers bound to 127.0.0.1. +// The production guard (networking.ProtectedDialerControl) would otherwise +// reject loopback addresses. +func TestMain(m *testing.M) { + webhook.SetAllowPrivateIPsForTestMain() + os.Exit(m.Run()) +} diff --git a/pkg/webhook/validating/main_test.go b/pkg/webhook/validating/main_test.go new file mode 100644 index 0000000000..6af7dc1ab2 --- /dev/null +++ b/pkg/webhook/validating/main_test.go @@ -0,0 +1,20 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package validating + +import ( + "os" + "testing" + + "github.com/stacklok/toolhive/pkg/webhook" +) + +// TestMain disables the webhook SSRF dial-time guard for the entire test +// binary so that webhook clients can dial httptest servers bound to 127.0.0.1. +// The production guard (networking.ProtectedDialerControl) would otherwise +// reject loopback addresses. +func TestMain(m *testing.M) { + webhook.SetAllowPrivateIPsForTestMain() + os.Exit(m.Run()) +}