From 024b7196de1bd3a32ec335a6c28357f9e8382954 Mon Sep 17 00:00:00 2001 From: Ettore Di Giacinto Date: Tue, 3 Nov 2026 08:18:20 +0000 Subject: [PATCH] fix(mcpconns): guard the dial when private addresses are allowed With mcp_connectors_allow_private on, a connector used a plain transport with the environment proxy and no address check, so its URL could reach cloud metadata addresses. Build the private path on the safehttp guard with private addresses allowed: it resolves once, checks every answer, and keeps the metadata addresses blocked. An optional DenyAddrs hook refuses further addresses, for example the server's own. Assisted-by: Claude Code:claude-sonnet-5-5 Signed-off-by: Ettore Di Giacinto --- core/services/studio/mcpconns/client.go | 11 +++++++- core/services/studio/mcpconns/service.go | 5 ++++ core/services/studio/mcpconns/service_test.go | 28 +++++++++++++++++++ docs/content/features/chats-connectors.md | 3 +- 4 files changed, 45 insertions(+), 2 deletions(-) diff --git a/core/services/studio/mcpconns/client.go b/core/services/studio/mcpconns/client.go index f435c89728..4433680f23 100644 --- a/core/services/studio/mcpconns/client.go +++ b/core/services/studio/mcpconns/client.go @@ -16,6 +16,7 @@ import ( "github.com/mudler/LocalAI/pkg/httpclient" "github.com/mudler/LocalAI/pkg/mcptransport" + "github.com/mudler/LocalAI/pkg/web/safehttp" ) // maxTools bounds what one server may offer. @@ -73,7 +74,15 @@ func (s *Service) httpClientFor(c Connector, sec Secrets) (*http.Client, error) } maps.Copy(headers, sec.Headers) if s.o.AllowPrivate() { - rt := &headerRT{base: httpclient.HardenedTransport(), origin: origin, headers: headers} + // Private networks are allowed, but not the cloud metadata addresses + // or the addresses of DenyAddrs. The guard resolves once, checks every + // answer and dials the checked address. No proxy: it would be dialed + // instead of the target and the target would not be checked. + g := safehttp.NewGuard(safehttp.GuardPolicy{AllowPrivate: true, DenyAddrs: s.o.DenyAddrs}, nil) + t := httpclient.HardenedTransport() + t.Proxy = nil + t.DialContext = g.DialContext + rt := &headerRT{base: t, origin: origin, headers: headers} return httpclient.New(httpclient.WithTransport(rt)), nil } rt := &headerRT{base: httpclient.New(httpclient.WithExternalOnly()).Transport, origin: origin, headers: headers} diff --git a/core/services/studio/mcpconns/service.go b/core/services/studio/mcpconns/service.go index d0fb0efcbd..c244b82e1c 100644 --- a/core/services/studio/mcpconns/service.go +++ b/core/services/studio/mcpconns/service.go @@ -7,6 +7,7 @@ import ( "encoding/json" "errors" "fmt" + "net/netip" "strings" "sync" "time" @@ -47,6 +48,10 @@ type Options struct { // AllowPrivate is the administrator's switch for addresses on private // networks. Nil: never allowed. It is read at each connection. AllowPrivate func() bool + // DenyAddrs lists addresses a connector may not reach even when private + // addresses are allowed, such as the server's own. Nil: none. Cloud + // metadata addresses are always refused. + DenyAddrs func() []netip.Prefix // Distributed refuses local commands: they would run on whichever frontend // serves the request. Distributed bool diff --git a/core/services/studio/mcpconns/service_test.go b/core/services/studio/mcpconns/service_test.go index 5a94b9048d..acba3406f0 100644 --- a/core/services/studio/mcpconns/service_test.go +++ b/core/services/studio/mcpconns/service_test.go @@ -6,6 +6,7 @@ import ( "encoding/json" "net/http" "net/http/httptest" + "net/netip" "os" "slices" "strings" @@ -364,6 +365,33 @@ var _ = Describe("Service", func() { Expect(p.Error).To(BeEmpty(), "with the administrator's switch on") }) + It("refuses cloud metadata addresses even when private addresses are allowed", func() { + e := newEnv() + ctx := specCtx() + e.private.Store(true) + for _, u := range []string{"http://169.254.169.254/mcp", "http://168.63.129.16/mcp", "http://[64:ff9b::a9fe:a9fe]/mcp"} { + c, err := e.svc.Create(ctx, "u1", httpInput("meta "+u, u)) + Expect(err).NotTo(HaveOccurred(), u) + start := time.Now() + p, _ := e.svc.Test(ctx, "u1", c.ID) + Expect(p.Error).NotTo(BeEmpty(), u) + Expect(time.Since(start)).To(BeNumerically("<", 2*time.Second), "%s: the address was dialed", u) + } + }) + + It("refuses the addresses it is told to deny even when private addresses are allowed", func() { + f := newFakeServer(0) + e := newEnv(func(o *mcpconns.Options) { + o.DenyAddrs = func() []netip.Prefix { return []netip.Prefix{netip.MustParsePrefix("127.0.0.0/8")} } + }) + ctx := specCtx() + c, err := e.svc.Create(ctx, "u1", httpInput("self", f.URL)) + Expect(err).NotTo(HaveOccurred()) + p, _ := e.svc.Test(ctx, "u1", c.ID) + Expect(p.Error).NotTo(BeEmpty()) + Expect(f.inits.Load()).To(BeZero(), "a denied address was reached") + }) + It("refuses a redirect to an internal address", func() { e := newEnv() ctx := specCtx() diff --git a/docs/content/features/chats-connectors.md b/docs/content/features/chats-connectors.md index 3c2ff67678..c4e2d33d05 100644 --- a/docs/content/features/chats-connectors.md +++ b/docs/content/features/chats-connectors.md @@ -505,7 +505,8 @@ used to probe the internal network, a connector may reach only public hosts: An administrator can allow private addresses with the runtime setting `mcp_connectors_allow_private` (Settings, Access and security; default off; no restart needed). Turn it on only when your users are trusted: with it, a connector -can reach services on the server's own network. Turning it off closes the open +can reach services on the server's own network. Cloud metadata addresses stay +refused even then, and a request does not use a proxy from the environment. Turning it off closes the open sessions that were made to such addresses: a tool handed out before is refused, and the next turn connects again under the new rule. Likewise, a user who stops being an administrator loses their running local commands at their next use.