Wire backend dial-control policy into vMCP serve.go - #6566
Conversation
The WithDialControl options on the vMCP backend client and session factory existed and were tested, but neither was connected to a real policy in production, so the shipped binary gained no private-address protection on backend dials. Implements changes for issue #6564: - Export networking.ProtectedDialerControl so serve.go can install the private-IP-blocking net.Dialer.Control hook - Add BackendAllowPrivateIP config toggle following the *AllowPrivateIP precedent; default refuses private/loopback/link-local dials - Build the policy once via backendDialControl and pass it to both NewSessionFactory and NewHTTPBackendClient - Regenerate CRD manifests and docs for the new spec.config field
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## main #6566 +/- ##
==========================================
- Coverage 78.94% 78.90% -0.05%
==========================================
Files 781 781
Lines 77784 77893 +109
==========================================
+ Hits 61410 61459 +49
- Misses 16369 16429 +60
Partials 5 5 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
PR #6567 generalized session.WithDialControl into session.WithDialControlResolver (per-workload). Update serve.go's session-factory wiring to the resolver shape. The policy is still global (config.BackendAllowPrivateIP), so the resolver returns the same hook for every workload; the shape leaves room for a future per-backend policy. The per-call backend client (client.WithDialControl) is unchanged.
#6551 added a miniredis.RunT(t) call to the embedded auth server test but omitted the github.com/alicebob/miniredis/v2 import (the dependency is already in go.mod), so the pkg/authserver/runner test package failed to compile. Add the import to restore the build.
tgrunnagle
left a comment
There was a problem hiding this comment.
Multi-Agent Consensus Review
Agents consulted: security, architecture, test-coverage, general-quality
Consensus Summary
| # | Finding | Consensus | Severity | Action |
|---|---|---|---|---|
| 1 | Default guard breaks backend connectivity in both deployment modes | 9/10 | HIGH | Fix |
| 2 | Proxy env vars silently defeat the SSRF guard | 7/10 | MEDIUM | Fix |
| 3 | No test exercises the actual wiring at either production call site | 9/10 | MEDIUM | Fix |
| 4 | Doc comment references a nonexistent pkg/vmcp/session.WithDialControl |
7/10 | MEDIUM | Fix |
| 5 | PR body omits required "Type of change" / "Test plan" sections | 8/10 | HIGH | Fix |
| 6 | PR body omits "API Compatibility" section despite CRD change | 8/10 | HIGH | Fix |
| 7 | PR left marked DRAFT while under review | 7/10 | MEDIUM | Fix |
| 8 | Unrelated miniredis import commit bundled into this PR |
8/10 | HIGH | Fix |
Overall
This wires an existing, already-tested SSRF/DNS-rebinding dial-control hook into the two production backend-dial paths in pkg/vmcp/cli/serve.go (closes #6564), using a single shared policy function so the session factory and per-call client can't drift apart. The design is sound, but the default it ships is unconditionally hostile to ToolHive's actual backend topology: Kubernetes backend URLs resolve to *.svc.cluster.local (private ClusterIP ranges — cmd/thv-operator/controllers/mcpserver_controller.go:568-570) and local/CLI-mode backends are Docker-proxied to 127.0.0.1 (pkg/workloads/manager.go:325, pkg/container/docker/client.go:1723). No CRD default, chart, or operator code sets backendAllowPrivateIp: true, so this default appears to break backend connectivity for essentially every existing deployment — the opposite of the PR's own claim that "no action is required for existing deployments." That needs a design call (narrow the guard's scope vs. change the default) before merge, backed by a test that actually dials through Serve()/discoverBackends() rather than only the extracted helper.
Separately, HTTP_PROXY/HTTPS_PROXY env vars can silently defeat the guard entirely (a caveat already documented elsewhere in the codebase but unaddressed here), and one unrelated commit (an auth-server test import fix) is bundled into this diff.
Documentation
pkg/networking/http_client.go:111— theProtectedDialerControldoc comment claims it can be "passed directly to the WithDialControl options in pkg/vmcp/client and pkg/vmcp/session," butpkg/vmcp/sessionhas noWithDialControl— onlyWithDialControlResolver, which needs a wrapping resolver closure.pkg/vmcp/config/config.go'sBackendAllowPrivateIPdoc comment (and the generated CRD description) should note the proxy-bypass caveat rather than unconditionally calling the default "the safe production behavior."
PR Process (not mappable to a diff line)
- Type of change / Test plan sections missing: the PR body doesn't follow
.github/pull_request_template.md— no "Type of change" box and no "Test plan" box are checked, both required. - API Compatibility section missing: this PR changes the
VirtualMCPServerCRD schema, which is exactly the case the template's "API Compatibility" section exists for; the section is absent entirely. - Draft status: the PR body opens with "# DRAFT - not ready for review" — worth resolving (or converting to an actual GitHub draft) before this goes through full review.
Generated with Claude Code
| // 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 { |
There was a problem hiding this comment.
[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
| // 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 { |
There was a problem hiding this comment.
[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
| // 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, |
There was a problem hiding this comment.
[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
| // 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. |
There was a problem hiding this comment.
[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).
| // 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
| "testing" | ||
| "time" | ||
|
|
||
| "github.com/alicebob/miniredis/v2" |
There was a problem hiding this comment.
[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
There was a problem hiding this comment.
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 main — go 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.
|
Abandoning this PR. Per the discussion on #6564, a guarded-by-default backend dial-control is a mismatch for vMCP's architecture — backends are private by construction (K8s ClusterIP, local The embedder options ( Note: this branch also carried an unrelated one-line build fix — the missing |
Closes #6564
Summary
The vMCP backend dial paths both exposed a
WithDialControloption to install an SSRF / DNS-rebinding guard, but neither was connected to a real policy in production —pkg/vmcp/cli/serve.gobuilt the per-call backend client and the session factory without passing a control. The capability existed and was tested, but the shipped vMCP binary gained no private-address protection on backend dials. This PR builds a private-IP-blocking dial control at theserve.gocomposition root and wires it into both call sites, with a config toggle to opt out for in-cluster / development use.Changes Made
pkg/networkingprotectedDialerControlasProtectedDialerControlsoserve.gocan install the private/loopback/link-local-blockingnet.Dialer.Controlhook. Internal callers updated to the exported name.pkg/vmcp/configBackendAllowPrivateIPconfig field (backendAllowPrivateIp), following the existing*AllowPrivateIPprecedent. Defaults tofalse(guarded).pkg/vmcp/cli/serve.gobackendDialControl, the single policy source shared by both production dial paths so they cannot drift. It returns the guarding hook by default andnil(no guard) whenBackendAllowPrivateIPis enabled.vmcpclient.NewHTTPBackendClient(per-call dials) viaclient.WithDialControl— a single address-blind hook.vmcpsession.NewSessionFactory(session-init dials) viasession.WithDialControlResolver— the per-workload resolver introduced by Generalize vMCP session dial-control into a per-workload resolver #6567 (see note below). Because the policy is global today, the resolver returns the same hook for every workload; the resolver shape leaves room for a future per-backend policy without re-wiring.Generated artifacts
VirtualMCPServerCRD manifests anddocs/operator/crd-api.mdfor the newspec.configfield.pkg/authserver/runner(drive-by build fix)github.com/alicebob/miniredis/v2import toembeddedauthserver_test.go. Support no-auth Redis for the embedded auth server and vMCP sessions #6551 added aminiredis.RunT(t)call but omitted the import (the dependency is already ingo.mod), leaving the test package non-compiling onmain. See the scope note below.Implementation Details
backendDialControl) guarantees the per-call client and the session factory always apply the same rule.Testing
TestProtectedDialerControl(pkg/networking): verifies the exported hook blocks loopback (IPv4/IPv6), RFC 1918, and link-local peers while allowing a public address.TestBackendDialControl(pkg/vmcp/cli): covers both the guarded default (nil config, empty config, andBackendAllowPrivateIP: falseall install a guard that refuses private and permits public dials) and the opt-out path (BackendAllowPrivateIP: truereturns no control).task testandtask lint-fixpass across the full tree.Additional Notes
backendAllowPrivateIpfield defaults to the safe guarded behavior; no action is required for existing deployments. Enable it only for in-cluster / development setups where backends legitimately resolve to private addresses.Special notes for reviewers
session.WithDialControlinto the per-workloadsession.WithDialControlResolverafter this work began. The session-factory call site was updated to the resolver shape; the per-callclient.WithDialControlwas not changed by Generalize vMCP session dial-control into a per-workload resolver #6567 and is used as-is.pkg/authserver/runnerchange is unrelated to the dial-control feature — it only restores the build after a missing import landed onmainvia Support no-auth Redis for the embedded auth server and vMCP sessions #6551. It is included here as a one-line unblocker; happy to split it into a dedicated hotfix PR againstmainif preferred.Generated with Claude Code