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 <mudler@localai.io>
This commit is contained in:
Ettore Di Giacinto committed 2026-11-03 08:39:12 +00:00
1 parent 59e3f01739
commit 024b7196de
4 files changed
+45 -2

No files matched your search

+10 -1
View File
@@ -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}
+5
View File
@@ -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
@@ -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()
+2 -1
View File
@@ -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.