Skip to content

Commit cb9b4ad

Browse files
JAORMXclaude
andcommitted
Block private and loopback dials in webhook HTTP client
A tenant with rights to create MCPWebhookConfig could previously point url at any HTTPS endpoint, including 169.254.169.254, 127.0.0.1, RFC1918 ranges, and IPv6 loopback or link-local addresses. The webhook HTTP transport built a bare http.Transport with no DialContext; the wrapping networking.ValidatingTransport only checks the URL scheme, not the resolved peer address, so cross-tenant access to cloud metadata or in-cluster services was unblocked. Wire networking.ProtectedDialerControl into the inner transport's DialContext so private, loopback, and link-local destinations are rejected at dial time, regardless of whether ValidatingTransport's HTTPS check is bypassed via InsecureAllowHTTP. The hook is held in an atomic.Pointer so test overrides remain race-free if a future test introduces t.Parallel(). Export protectedDialerControl as ProtectedDialerControl in pkg/networking so the webhook package can install it without duplicating the body. Add cross-package test injection (SetDialerControlForTesting, SetDialerControlForTestMain, AllowAnyDialerControl) so existing httptest-based tests in pkg/webhook and its subpackages continue to work; subpackage TestMain functions install the permissive override at suite startup. The pkg/runner webhook middleware integration test installs the same override, since its production webhook clients dial httptest servers on 127.0.0.1. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 6f63ac0 commit cb9b4ad

7 files changed

Lines changed: 209 additions & 8 deletions

File tree

pkg/networking/http_client.go

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -35,8 +35,11 @@ const HttpsScheme = "https"
3535
// HttpScheme is the HTTP scheme
3636
const HttpScheme = "http"
3737

38-
// Dialer control function for validating addresses prior to connection
39-
func protectedDialerControl(_, address string, _ syscall.RawConn) error {
38+
// ProtectedDialerControl is a Dialer control function for validating addresses
39+
// prior to connection. It returns an error if the resolved address points at a
40+
// private, loopback, or link-local IP, providing an SSRF guard at dial time.
41+
// Pass it to (&net.Dialer{Control: ...}).DialContext on outbound HTTP transports.
42+
func ProtectedDialerControl(_, address string, _ syscall.RawConn) error {
4043
err := AddressReferencesPrivateIp(address)
4144
if err != nil {
4245
return err
@@ -164,7 +167,7 @@ func (b *HttpClientBuilder) Build() (*http.Client, error) {
164167

165168
if !b.allowPrivate {
166169
transport.DialContext = (&net.Dialer{
167-
Control: protectedDialerControl,
170+
Control: ProtectedDialerControl,
168171
}).DialContext
169172
}
170173

pkg/runner/webhook_integration_test.go

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,8 +21,13 @@ import (
2121

2222
// TestWebhookMiddlewareChainIntegration tests the full execution of the webhook middleware chain
2323
// populated by PopulateMiddlewareConfigs in the runner.
24+
//
25+
//nolint:paralleltest // mutates package-level dialerControl hook via SetDialerControlForTesting
2426
func TestWebhookMiddlewareChainIntegration(t *testing.T) {
25-
t.Parallel()
27+
// The webhook clients built by the middleware factories use the production
28+
// dialer guard, which rejects the 127.0.0.1 httptest servers below as part of
29+
// the SSRF protection. Install the permissive test hook so the dial succeeds.
30+
webhook.SetDialerControlForTesting(t, webhook.AllowAnyDialerControl)
2631

2732
// 1. Set up a mutating webhook server that adds a new argument field
2833
mutatingServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {

pkg/webhook/client.go

Lines changed: 36 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,11 +18,33 @@ import (
1818
"os"
1919
"path/filepath"
2020
"strconv"
21+
"sync/atomic"
22+
"syscall"
2123
"time"
2224

2325
"github.com/stacklok/toolhive/pkg/networking"
2426
)
2527

28+
// dialerControlFunc is the type of the Control hook installed on the underlying
29+
// *net.Dialer used by the webhook HTTP transport.
30+
type dialerControlFunc func(network, address string, c syscall.RawConn) error
31+
32+
// dialerControl holds the active Control hook. By default it wraps
33+
// networking.ProtectedDialerControl, which rejects dials to private, loopback,
34+
// and link-local addresses (SSRF guard).
35+
//
36+
// It is wrapped in atomic.Pointer so cross-goroutine writes from tests
37+
// (SetDialerControlForTesting / SetDialerControlForTestMain) and
38+
// cross-goroutine reads from the production dial path are race-free, even if
39+
// a future test introduces t.Parallel(). Production callers must not
40+
// reassign this variable.
41+
var dialerControl atomic.Pointer[dialerControlFunc]
42+
43+
func init() {
44+
fn := dialerControlFunc(networking.ProtectedDialerControl)
45+
dialerControl.Store(&fn)
46+
}
47+
2648
// Client is an HTTP client for calling webhook endpoints.
2749
type Client struct {
2850
httpClient *http.Client
@@ -127,7 +149,10 @@ func (c *Client) doHTTPCall(ctx context.Context, body []byte) ([]byte, error) {
127149
httpReq.Header.Set(TimestampHeader, strconv.FormatInt(timestamp, 10))
128150
}
129151

130-
// #nosec G704 -- URL is validated in Config.Validate and we use ValidatingTransport for SSRF protection.
152+
// #nosec G704 -- URL is validated in Config.Validate; the inner transport's
153+
// dialer rejects private/loopback/link-local addresses (SSRF guard), and
154+
// ValidatingTransport additionally enforces HTTPS unless InsecureAllowHTTP
155+
// is set for the configured TLS profile.
131156
resp, err := c.httpClient.Do(httpReq)
132157
if err != nil {
133158
return nil, classifyError(c.config.Name, err)
@@ -199,15 +224,23 @@ func (c *Client) hmacSecretForRequest(ctx context.Context) ([]byte, error) {
199224
return secret, nil
200225
}
201226

202-
// buildTransport creates an http.RoundTripper with the specified TLS configuration,
203-
// always wrapped in ValidatingTransport for SSRF protection.
227+
// buildTransport creates an http.RoundTripper with the specified TLS configuration.
228+
// The inner *http.Transport installs a dialer Control hook (the package-level
229+
// dialerControl) that rejects connections to private, loopback, and link-local
230+
// addresses, providing an SSRF guard regardless of TLS or HTTP mode. The outer
231+
// ValidatingTransport additionally enforces HTTPS unless InsecureAllowHTTP is set.
204232
func buildTransport(tlsCfg *TLSConfig) (http.RoundTripper, error) {
205233
transport := &http.Transport{
206234
TLSHandshakeTimeout: 10 * time.Second,
207235
ResponseHeaderTimeout: 10 * time.Second,
208236
MaxIdleConns: 100,
209237
MaxIdleConnsPerHost: 10,
210238
IdleConnTimeout: 90 * time.Second,
239+
DialContext: (&net.Dialer{
240+
Control: func(network, address string, c syscall.RawConn) error {
241+
return (*dialerControl.Load())(network, address, c)
242+
},
243+
}).DialContext,
211244
}
212245

213246
// allowHTTP is true when InsecureSkipVerify is set, which also covers in-cluster

pkg/webhook/client_test.go

Lines changed: 71 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -323,8 +323,9 @@ func TestClientHMACSigningHeaders(t *testing.T) {
323323
assert.NotEmpty(t, capturedHeaders.Get(TimestampHeader), "expected %s header", TimestampHeader)
324324
}
325325

326+
//nolint:paralleltest // mutates package-level dialerControl hook
326327
func TestClientRereadsMountedHMACSecret(t *testing.T) {
327-
t.Parallel()
328+
SetDialerControlForTesting(t, AllowAnyDialerControl)
328329

329330
type signedRequest struct {
330331
body []byte
@@ -643,6 +644,75 @@ func TestBuildTransport(t *testing.T) {
643644
}
644645
}
645646

647+
// TestClientSSRFGuardBlocksPrivateAddress verifies that the production webhook
648+
// client refuses to dial private, loopback, and link-local addresses (cloud
649+
// metadata IP). The dial-time SSRF guard is the load-bearing protection against
650+
// a tenant pointing MCPWebhookConfig.url at internal services.
651+
//
652+
//nolint:paralleltest // exercises the production package-level dialerControl hook
653+
func TestClientSSRFGuardBlocksPrivateAddress(t *testing.T) {
654+
tests := []struct {
655+
name string
656+
host string
657+
}{
658+
{name: "loopback IPv4", host: "127.0.0.1"},
659+
{name: "RFC1918 private", host: "10.0.0.1"},
660+
{name: "cloud metadata link-local", host: "169.254.169.254"},
661+
{name: "loopback IPv6", host: "[::1]"},
662+
{name: "link-local IPv6", host: "[fe80::1]"},
663+
{name: "ULA IPv6", host: "[fc00::1]"},
664+
}
665+
666+
for _, tt := range tests {
667+
t.Run(tt.name, func(t *testing.T) {
668+
// Use HTTPS scheme so ValidatingTransport does not short-circuit the
669+
// request before the dial. Use a high-numbered port so we never
670+
// actually reach a real listener; the dialer guard fires first.
671+
cfg := Config{
672+
Name: "ssrf-test",
673+
URL: "https://" + tt.host + ":59999/webhook",
674+
Timeout: 2 * time.Second,
675+
FailurePolicy: FailurePolicyFail,
676+
}
677+
client, err := NewClient(cfg, TypeValidating, nil)
678+
require.NoError(t, err)
679+
680+
req := &Request{
681+
Version: APIVersion,
682+
UID: "ssrf-uid",
683+
Timestamp: time.Now(),
684+
}
685+
_, callErr := client.Call(t.Context(), req)
686+
require.Error(t, callErr)
687+
688+
var netErr *NetworkError
689+
require.True(t, errors.As(callErr, &netErr),
690+
"expected *NetworkError, got %T: %v", callErr, callErr)
691+
assert.Contains(t, callErr.Error(), networking.ErrPrivateIpAddress,
692+
"error should reflect dialer rejection of private/loopback/link-local address")
693+
})
694+
}
695+
}
696+
697+
// TestBuildTransportInstallsDialerGuard is a narrow unit check that buildTransport
698+
// installs a non-nil DialContext on the inner *http.Transport. The integration
699+
// coverage in TestClientSSRFGuardBlocksPrivateAddress is the load-bearing test;
700+
// this assertion just ensures the wiring does not silently regress to a bare
701+
// transport with no Control hook.
702+
func TestBuildTransportInstallsDialerGuard(t *testing.T) {
703+
t.Parallel()
704+
705+
rt, err := buildTransport(nil)
706+
require.NoError(t, err)
707+
require.NotNil(t, rt)
708+
709+
vt, ok := rt.(*networking.ValidatingTransport)
710+
require.True(t, ok, "expected *networking.ValidatingTransport, got %T", rt)
711+
inner, ok := vt.Transport.(*http.Transport)
712+
require.True(t, ok, "expected inner *http.Transport, got %T", vt.Transport)
713+
assert.NotNil(t, inner.DialContext, "buildTransport must install a DialContext that runs the SSRF dialer guard")
714+
}
715+
646716
func TestClassifyError(t *testing.T) {
647717
t.Parallel()
648718

pkg/webhook/dialer_testing.go

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc.
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
package webhook
5+
6+
import (
7+
"syscall"
8+
"testing"
9+
)
10+
11+
// The functions in this file are test-only helpers that intentionally live in a
12+
// non-_test.go file so that sub-package tests (e.g. pkg/webhook/validating,
13+
// pkg/webhook/mutating) can call into them via TestMain. There is no clean
14+
// alternative for cross-package test-time injection of the package-level
15+
// dialerControl hook, so these helpers are exported. The testing.TB argument
16+
// (or the explicit "ForTestMain" suffix) is the signal that the call is
17+
// test-scoped; production code MUST NOT call any of them.
18+
19+
// SetDialerControlForTesting overrides the package-level dialerControl hook
20+
// for the duration of tb. It is the sanctioned way for tests to bypass the
21+
// production SSRF dial-time guard so they can talk to httptest servers,
22+
// which always bind 127.0.0.1. The previous value is restored via t.Cleanup.
23+
//
24+
// Production code MUST NOT call this function. The testing.TB argument is the
25+
// signal that the call is test-scoped.
26+
func SetDialerControlForTesting(tb testing.TB, control func(network, address string, c syscall.RawConn) error) {
27+
tb.Helper()
28+
prev := dialerControl.Load()
29+
fn := dialerControlFunc(control)
30+
dialerControl.Store(&fn)
31+
tb.Cleanup(func() { dialerControl.Store(prev) })
32+
}
33+
34+
// SetDialerControlForTestMain installs control as the dialer guard for the
35+
// rest of the test binary's lifetime. Use this in TestMain in sub-packages
36+
// whose entire test suite legitimately dials httptest servers bound to
37+
// 127.0.0.1. There is no restore — the binary exits anyway.
38+
//
39+
// Production code MUST NOT call this function.
40+
func SetDialerControlForTestMain(control func(network, address string, c syscall.RawConn) error) {
41+
fn := dialerControlFunc(control)
42+
dialerControl.Store(&fn)
43+
}
44+
45+
// AllowAnyDialerControl is a permissive Control function for tests that
46+
// need to dial httptest servers on 127.0.0.1. It performs no validation
47+
// and always returns nil.
48+
//
49+
// Production code MUST NOT use this function.
50+
func AllowAnyDialerControl(_, _ string, _ syscall.RawConn) error { return nil }

pkg/webhook/mutating/main_test.go

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc.
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
package mutating
5+
6+
import (
7+
"os"
8+
"testing"
9+
10+
"github.com/stacklok/toolhive/pkg/webhook"
11+
)
12+
13+
// TestMain installs a permissive dialer control hook for the entire test
14+
// binary so that webhook clients can dial httptest servers bound to 127.0.0.1.
15+
// The production hook (networking.ProtectedDialerControl) would otherwise reject
16+
// loopback addresses as part of the SSRF guard.
17+
func TestMain(m *testing.M) {
18+
webhook.SetDialerControlForTestMain(webhook.AllowAnyDialerControl)
19+
os.Exit(m.Run())
20+
}
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc.
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
package validating
5+
6+
import (
7+
"os"
8+
"testing"
9+
10+
"github.com/stacklok/toolhive/pkg/webhook"
11+
)
12+
13+
// TestMain installs a permissive dialer control hook for the entire test
14+
// binary so that webhook clients can dial httptest servers bound to 127.0.0.1.
15+
// The production hook (networking.ProtectedDialerControl) would otherwise reject
16+
// loopback addresses as part of the SSRF guard.
17+
func TestMain(m *testing.M) {
18+
webhook.SetDialerControlForTestMain(webhook.AllowAnyDialerControl)
19+
os.Exit(m.Run())
20+
}

0 commit comments

Comments
 (0)