Skip to content
Closed
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
Original file line number Diff line number Diff line change
Expand Up @@ -2469,6 +2469,15 @@ spec:
minimum: 1
type: integer
type: object
backendAllowPrivateIp:
description: |-
BackendAllowPrivateIP allows the virtual MCP server to dial backend
endpoints that resolve to private, loopback, or link-local addresses.
When false (the default), backend dials into those ranges are refused
after DNS resolution to blunt SSRF / DNS-rebinding, which is the safe
production behavior. Enable only for in-cluster or development
deployments where backends legitimately resolve to private addresses.
type: boolean
backends:
description: |-
Backends defines pre-configured backend servers for static mode.
Expand Down Expand Up @@ -7432,6 +7441,15 @@ spec:
minimum: 1
type: integer
type: object
backendAllowPrivateIp:
description: |-
BackendAllowPrivateIP allows the virtual MCP server to dial backend
endpoints that resolve to private, loopback, or link-local addresses.
When false (the default), backend dials into those ranges are refused
after DNS resolution to blunt SSRF / DNS-rebinding, which is the safe
production behavior. Enable only for in-cluster or development
deployments where backends legitimately resolve to private addresses.
type: boolean
backends:
description: |-
Backends defines pre-configured backend servers for static mode.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2472,6 +2472,15 @@ spec:
minimum: 1
type: integer
type: object
backendAllowPrivateIp:
description: |-
BackendAllowPrivateIP allows the virtual MCP server to dial backend
endpoints that resolve to private, loopback, or link-local addresses.
When false (the default), backend dials into those ranges are refused
after DNS resolution to blunt SSRF / DNS-rebinding, which is the safe
production behavior. Enable only for in-cluster or development
deployments where backends legitimately resolve to private addresses.
type: boolean
backends:
description: |-
Backends defines pre-configured backend servers for static mode.
Expand Down Expand Up @@ -7435,6 +7444,15 @@ spec:
minimum: 1
type: integer
type: object
backendAllowPrivateIp:
description: |-
BackendAllowPrivateIP allows the virtual MCP server to dial backend
endpoints that resolve to private, loopback, or link-local addresses.
When false (the default), backend dials into those ranges are refused
after DNS resolution to blunt SSRF / DNS-rebinding, which is the safe
production behavior. Enable only for in-cluster or development
deployments where backends legitimately resolve to private addresses.
type: boolean
backends:
description: |-
Backends defines pre-configured backend servers for static mode.
Expand Down
1 change: 1 addition & 0 deletions docs/operator/crd-api.md

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions pkg/authserver/runner/embeddedauthserver_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import (
"testing"
"time"

"github.com/alicebob/miniredis/v2"

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[HIGH] Unrelated miniredis import commit bundled into this PR (Consensus: 8/10)

This PR's own description covers only pkg/networking, pkg/vmcp/config, pkg/vmcp/cli/serve.go, and generated CRD artifacts — nothing about the auth server. This import fixes a pre-existing compile error for a no-auth-Redis test, unrelated to SSRF/dial-control wiring. Per this repo's PR-scope rule, each PR should contain only related changes.

Consider rebasing this commit out onto its own PR against main (or dropping it here if it's already landed elsewhere).

Raised by: general-quality, test-coverage

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Intentional and kept here as a build fix. #6551 added a miniredis.RunT(t) call to this test but omitted the github.com/alicebob/miniredis/v2 import (the dependency is already in go.mod), so pkg/authserver/runner's test package does not compile on current maingo vet/go test/golangci-lint over ./... all fail with undefined: miniredis (go build ./... passes because it skips test files, which is why it slipped through). The one-line import add restores the build, which this PR needs in order to run task test/task lint-fix at all. Agree it's out of scope for the dial-control feature; happy to split it into a standalone hotfix PR against main if you'd prefer it not ride along here.

"github.com/ory/fosite"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
Expand Down
31 changes: 31 additions & 0 deletions pkg/networking/backend_transport_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,37 @@ type roundTripperFunc func(*http.Request) (*http.Response, error)

func (f roundTripperFunc) RoundTrip(r *http.Request) (*http.Response, error) { return f(r) }

// TestProtectedDialerControl verifies the exported dial-control hook refuses
// private/loopback/link-local peers while allowing a public one. It is the
// policy wired into the vMCP backend dial paths by pkg/vmcp/cli.
func TestProtectedDialerControl(t *testing.T) {
t.Parallel()

tests := []struct {
name string
address string
wantErr bool
}{
{name: "loopback IPv4 blocked", address: "127.0.0.1:8080", wantErr: true},
{name: "loopback IPv6 blocked", address: "[::1]:8080", wantErr: true},
{name: "RFC 1918 blocked", address: "10.0.0.5:443", wantErr: true},
{name: "link-local blocked", address: "169.254.169.254:80", wantErr: true},
{name: "public IPv4 allowed", address: "93.184.216.34:443", wantErr: false},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
err := ProtectedDialerControl("tcp", tt.address, nil)
if tt.wantErr {
require.Error(t, err)
} else {
require.NoError(t, err)
}
})
}
}

// TestCloneDefaultTransportWithDialControl verifies the shared backend
// transport construction: a nil control clones DefaultTransport (a distinct
// value that still reaches the server), and a non-nil control's hook fires on
Expand Down
16 changes: 12 additions & 4 deletions pkg/networking/http_client.go
Original file line number Diff line number Diff line change
Expand Up @@ -101,8 +101,16 @@ func SameHostRedirectPolicy() func(req *http.Request, via []*http.Request) error
}
}

// Dialer control function for validating addresses prior to connection
func protectedDialerControl(_, address string, _ syscall.RawConn) error {
// ProtectedDialerControl is a net.Dialer.Control hook that refuses to connect to
// private, loopback, or link-local addresses. It runs on the resolved peer IP
// (in address) before the TCP handshake, so it also defends against DNS
// rebinding: a name that passed a host-based check can still resolve to a
// blocked IP, and this hook catches that at dial time.
//
// The signature matches net.Dialer.Control exactly, so it can be passed
// directly to the WithDialControl options in pkg/vmcp/client and
// pkg/vmcp/session, or installed on a net.Dialer.
Comment on lines +110 to +112

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[MEDIUM] Doc comment references a nonexistent pkg/vmcp/session.WithDialControl (Consensus: 7/10)

pkg/vmcp/session has no WithDialControl — only WithDialControlResolver, which takes a resolver function, not the hook directly. ProtectedDialerControl must be wrapped in a resolver closure to be used there (exactly as this PR's own serve.go does).

Suggested change
// The signature matches net.Dialer.Control exactly, so it can be passed
// directly to the WithDialControl options in pkg/vmcp/client and
// pkg/vmcp/session, or installed on a net.Dialer.
// The signature matches net.Dialer.Control exactly, so it can be passed
// directly to pkg/vmcp/client's WithDialControl option, or wrapped in a
// workload-invariant resolver for pkg/vmcp/session's WithDialControlResolver
// option (see pkg/vmcp/cli/serve.go's backendDialControl for an example).

Raised by: architecture

func ProtectedDialerControl(_, address string, _ syscall.RawConn) error {
err := AddressReferencesPrivateIp(address)
if err != nil {
return err
Expand All @@ -122,7 +130,7 @@ func protectedDialerControl(_, address string, _ syscall.RawConn) error {
// operator-configured target is public; SameHostRedirectPolicy is the
// redirect-following counterpart.
func NewPrivateIPBlockingDialContext() func(ctx context.Context, network, addr string) (net.Conn, error) {
return (&net.Dialer{Control: protectedDialerControl}).DialContext
return (&net.Dialer{Control: ProtectedDialerControl}).DialContext
}

// Dial timeouts applied to backend connections. Both match the Go standard
Expand Down Expand Up @@ -431,7 +439,7 @@ func (b *HttpClientBuilder) Build() (*http.Client, error) {

if !b.allowPrivate {
transport.DialContext = (&net.Dialer{
Control: protectedDialerControl,
Control: ProtectedDialerControl,
}).DialContext
}

Expand Down
51 changes: 48 additions & 3 deletions pkg/vmcp/cli/serve.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import (
"net"
"os"
"path/filepath"
"syscall"
"time"

"go.opentelemetry.io/otel/trace"
Expand All @@ -31,6 +32,7 @@ import (
"github.com/stacklok/toolhive/pkg/container/runtime"
"github.com/stacklok/toolhive/pkg/groups"
"github.com/stacklok/toolhive/pkg/migration"
"github.com/stacklok/toolhive/pkg/networking"
"github.com/stacklok/toolhive/pkg/telemetry"
"github.com/stacklok/toolhive/pkg/versions"
"github.com/stacklok/toolhive/pkg/vmcp"
Expand Down Expand Up @@ -144,6 +146,15 @@ func Serve(ctx context.Context, cfg ServeConfig) error {
slog.Info("audit logging enabled with default configuration")
}

// Warn when the backend SSRF / DNS-rebinding guard is disabled. Both backend
// dial paths (per-call client and session factory) then dial private ranges
// unchecked; this is intended only for in-cluster / development use.
if vmcpCfg.BackendAllowPrivateIP {

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[MEDIUM] Proxy env vars silently defeat the SSRF guard (Consensus: 7/10)

CloneDefaultTransportWithDialControl clones http.DefaultTransport, which carries Proxy: http.ProxyFromEnvironment. When HTTP_PROXY/HTTPS_PROXY is set, the dial target becomes the proxy's address, so this guard validates the proxy's IP, not the backend's — a documented caveat on WithDialControl/WithDialControlResolver that this composition root doesn't address.

Consider disabling proxying on the guarded transport (Proxy: nil) when the guard is active, or at minimum warn at startup (alongside the existing backendAllowPrivateIp warning) when a proxy env var is set.

Raised by: security

slog.Warn("backendAllowPrivateIp is enabled; backend dials into private, loopback, and " +
"link-local ranges are NOT blocked (SSRF / DNS-rebinding guard disabled). " +
"Intended for in-cluster / development use only.")
}

// Load auth server config from sibling file if present.
// Skip in quick mode (no config file) — there is no sibling directory to search.
var authServerRC *authserverconfig.RunConfig
Expand Down Expand Up @@ -358,6 +369,19 @@ func Serve(ctx context.Context, cfg ServeConfig) error {
sessionFactoryOpts,
vmcpsession.WithRequestTimeoutResolver(backendRequestTimeoutResolver(vmcpCfg)),
)
// Guard session-init dials against SSRF / DNS-rebinding into private ranges,
// unless the operator opted out for in-cluster / development use. The same
// policy guards the per-call backend client built in discoverBackends.
//
// The policy is currently global (config.BackendAllowPrivateIP), so the
// per-workload resolver returns the same hook for every backend; its shape
// leaves room for a future per-backend policy without re-wiring here.
if dialControl := backendDialControl(vmcpCfg); dialControl != nil {
sessionFactoryOpts = append(sessionFactoryOpts,

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[MEDIUM] No test exercises the actual wiring at either production call site (Consensus: 9/10)

TestBackendDialControl only unit-tests the standalone backendDialControl(cfg) helper. Nothing calls Serve()/discoverBackends() and asserts the hook actually reaches WithDialControlResolver here or vmcpclient.WithDialControl in discoverBackends. The closure that adapts the helper into a per-workload resolver has no direct test either. This is exactly the class of gap that would let the connectivity-breaking default above ship unnoticed.

Consider extracting a backendDialControlResolver(cfg) helper (mirroring backendRequestTimeoutResolver's shape) that can be unit-tested directly, and adding a test that builds the client/session factory through the real Serve/discoverBackends path against a loopback backend.

Raised by: security, architecture, test-coverage

vmcpsession.WithDialControlResolver(func(string) func(network, address string, c syscall.RawConn) error {
return dialControl
}))
}
sessionFactory := vmcpsession.NewSessionFactory(outgoingRegistry, sessionFactoryOpts...)

// When the optimizer is enabled, its meta-tools are pass-through tools.
Expand Down Expand Up @@ -545,6 +569,22 @@ func backendRequestTimeoutResolver(cfg *config.Config) func(workloadID string) t
}
}

// backendDialControl returns the net.Dialer.Control hook that guards backend
// dials against SSRF / DNS-rebinding into private, loopback, or link-local
// ranges. It is the single policy source shared by both production dial paths —
// the per-call backend client (discoverBackends) and the session factory
// (Serve) — so neither can drift from the other.
//
// It returns nil (no guard) when cfg.BackendAllowPrivateIP is true, which is the
// opt-out for in-cluster / development deployments where backends legitimately
// resolve to private addresses. The default (false) returns the guarding hook.
func backendDialControl(cfg *config.Config) func(network, address string, c syscall.RawConn) error {
if cfg != nil && cfg.BackendAllowPrivateIP {

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[HIGH] Default guard breaks backend connectivity in both deployment modes (Consensus: 9/10)

backendDialControl defaults to the guarding hook whenever BackendAllowPrivateIP is false — but ToolHive's own backend addresses are private by construction: K8s backends resolve to *.svc.cluster.local (ClusterIP range, see cmd/thv-operator/controllers/mcpserver_controller.go:568-570), and local/CLI backends are Docker-proxied to 127.0.0.1 (pkg/workloads/manager.go:325). Nothing in cmd/thv-operator/ or deploy/ sets backendAllowPrivateIp: true, so this default appears to break backend connectivity for real deployments in both topologies — contradicting the PR body's claim that no action is required for existing deployments.

Consider scoping the guard to the actual SSRF risk (redirects / re-resolution after the initial connect) rather than the operator's own initially-configured target — see the existing NewHostScopedClientBuilder pattern that already special-cases loopback/operator-configured-private hosts (pkg/networking/http_client.go:352) — or default the opt-out to true for the operator/local topologies.

Raised by: security

return nil
}
return networking.ProtectedDialerControl
}

// loadAndValidateConfig loads and validates the vMCP configuration file.
func loadAndValidateConfig(configPath string) (*config.Config, error) {
slog.Info(fmt.Sprintf("Loading configuration from: %s", configPath))
Expand Down Expand Up @@ -649,10 +689,15 @@ func discoverBackends(
return nil, nil, nil, fmt.Errorf("failed to create outgoing authentication registry: %w", err)
}

backendClient, err := vmcpclient.NewHTTPBackendClient(
outgoingRegistry,
clientOpts := []vmcpclient.Option{
vmcpclient.WithRequestTimeoutResolver(backendRequestTimeoutResolver(cfg)),
)
}
// Guard per-call backend dials against SSRF / DNS-rebinding into private
// ranges, unless the operator opted out. Mirrors the session factory in Serve.
if dialControl := backendDialControl(cfg); dialControl != nil {
clientOpts = append(clientOpts, vmcpclient.WithDialControl(dialControl))
}
backendClient, err := vmcpclient.NewHTTPBackendClient(outgoingRegistry, clientOpts...)
if err != nil {
return nil, nil, nil, fmt.Errorf("failed to create backend client: %w", err)
}
Expand Down
25 changes: 25 additions & 0 deletions pkg/vmcp/cli/serve_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,31 @@ func TestBackendRequestTimeoutResolver(t *testing.T) {
}
}

// TestBackendDialControl covers the guarded default and the opt-out path for the
// backend dial-control policy wired into both production dial paths in serve.go.
func TestBackendDialControl(t *testing.T) {
t.Parallel()

t.Run("guarded by default", func(t *testing.T) {
t.Parallel()
// Nil config and BackendAllowPrivateIP=false both mean "guard".
for _, cfg := range []*config.Config{nil, {}, {BackendAllowPrivateIP: false}} {
control := backendDialControl(cfg)
require.NotNil(t, control, "guarded default must install a dial control")
// The installed control must refuse a private-range dial.
require.Error(t, control("tcp", "127.0.0.1:8080", nil))
// ...and permit a public one.
require.NoError(t, control("tcp", "93.184.216.34:443", nil))
}
})

t.Run("opt-out disables the guard", func(t *testing.T) {
t.Parallel()
control := backendDialControl(&config.Config{BackendAllowPrivateIP: true})
require.Nil(t, control, "opt-out must return no dial control so private dials are allowed")
})
}

// TestLoadAndValidateConfig covers all config-loading paths.
func TestLoadAndValidateConfig(t *testing.T) {
t.Parallel()
Expand Down
9 changes: 9 additions & 0 deletions pkg/vmcp/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,15 @@ type Config struct {
// Operational configures operational settings.
Operational *OperationalConfig `json:"operational,omitempty" yaml:"operational,omitempty"`

// BackendAllowPrivateIP allows the virtual MCP server to dial backend
// endpoints that resolve to private, loopback, or link-local addresses.
// When false (the default), backend dials into those ranges are refused
// after DNS resolution to blunt SSRF / DNS-rebinding, which is the safe
// production behavior. Enable only for in-cluster or development
// deployments where backends legitimately resolve to private addresses.
// +optional
BackendAllowPrivateIP bool `json:"backendAllowPrivateIp,omitempty" yaml:"backendAllowPrivateIp,omitempty"`

// Metadata stores additional configuration metadata.
Metadata map[string]string `json:"metadata,omitempty" yaml:"metadata,omitempty"`

Expand Down
Loading