mirror of
https://github.com/mudler/LocalAI.git
synced 2026-11-05 20:13:23 -05:00
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:
4 files changed
+45
-2
No files matched your search
@@ -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}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in new issue
Block a user