Skip to content

Commit 2cbb3c9

Browse files
localai-botmudler
andauthored
fix(gallery): block SSRF in gallery config URL fetch (#10665) (#10673)
POST /models/apply with an empty "id" fetches the attacker-supplied "url" gallery config directly via http.Client, with no check that the URL resolves to a public IP. In the default Docker deployment no API key is configured, so any network-reachable client can coerce LocalAI into issuing requests to internal services or cloud-metadata endpoints (and exfiltrate a small slice of the response through the job error message). Guard the config fetch chokepoints (GetGalleryConfigFromURL and GetGalleryConfigFromURLWithContext, which back both the /models/apply worker and gallery installs) with utils.ValidateExternalURL, matching the protection already applied to the CORS proxy and image/video/audio download paths. Only plain http(s) URLs are validated; non-network schemes (huggingface://, github:, oci://, ollama://, file://) resolve to fixed public services or local files and are left untouched. Assisted-by: Claude:claude-opus-4-8 [Claude Code] Signed-off-by: Ettore Di Giacinto <mudler@localai.io> Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
1 parent 1152acc commit 2cbb3c9

2 files changed

Lines changed: 74 additions & 0 deletions

File tree

core/gallery/gallery.go

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,14 +15,35 @@ import (
1515
"github.com/mudler/LocalAI/core/config"
1616
"github.com/mudler/LocalAI/pkg/downloader"
1717
"github.com/mudler/LocalAI/pkg/system"
18+
"github.com/mudler/LocalAI/pkg/utils"
1819
"github.com/mudler/LocalAI/pkg/xsync"
1920
"github.com/mudler/xlog"
2021

2122
"gopkg.in/yaml.v3"
2223
)
2324

25+
// validateGalleryConfigURL guards the gallery config fetch against SSRF. A
26+
// gallery config URL can be attacker-controlled (e.g. POST /models/apply with
27+
// an empty id fetches it directly), so a plain http(s) URL must not be allowed
28+
// to reach private, loopback, link-local or cloud-metadata addresses. Other
29+
// schemes (huggingface://, github:, oci://, ollama://, file://) resolve to
30+
// fixed public services or local files and are not a network-SSRF vector, so
31+
// they are left untouched.
32+
// See https://github.com/mudler/LocalAI/issues/10665
33+
func validateGalleryConfigURL(rawURL string) error {
34+
lower := strings.ToLower(strings.TrimSpace(rawURL))
35+
if strings.HasPrefix(lower, "http://") || strings.HasPrefix(lower, "https://") {
36+
return utils.ValidateExternalURL(rawURL)
37+
}
38+
return nil
39+
}
40+
2441
func GetGalleryConfigFromURL[T any](url string, basePath string) (T, error) {
2542
var config T
43+
if err := validateGalleryConfigURL(url); err != nil {
44+
xlog.Error("refusing to fetch gallery config", "error", err, "url", url)
45+
return config, err
46+
}
2647
uri := downloader.URI(url)
2748
err := uri.ReadWithCallback(basePath, func(url string, d []byte) error {
2849
return yaml.Unmarshal(d, &config)
@@ -36,6 +57,10 @@ func GetGalleryConfigFromURL[T any](url string, basePath string) (T, error) {
3657

3758
func GetGalleryConfigFromURLWithContext[T any](ctx context.Context, url string, basePath string) (T, error) {
3859
var config T
60+
if err := validateGalleryConfigURL(url); err != nil {
61+
xlog.Error("refusing to fetch gallery config", "error", err, "url", url)
62+
return config, err
63+
}
3964
uri := downloader.URI(url)
4065
err := uri.ReadWithAuthorizationAndCallback(ctx, basePath, "", func(url string, d []byte) error {
4166
return yaml.Unmarshal(d, &config)

core/gallery/request_test.go

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,10 @@
11
package gallery_test
22

33
import (
4+
"context"
5+
"net/http"
6+
"net/http/httptest"
7+
48
. "github.com/mudler/LocalAI/core/gallery"
59
. "github.com/onsi/ginkgo/v2"
610
. "github.com/onsi/gomega"
@@ -19,4 +23,49 @@ var _ = Describe("Gallery API tests", func() {
1923
Expect(e.Name).To(Equal("gpt4all-j"))
2024
})
2125
})
26+
27+
// SSRF guard: a user-supplied gallery config URL (e.g. POST /models/apply
28+
// with an empty id) must not be able to reach internal network addresses.
29+
// See https://github.com/mudler/LocalAI/issues/10665
30+
Context("SSRF protection on config URLs", func() {
31+
var server *httptest.Server
32+
33+
BeforeEach(func() {
34+
// A reachable internal server that would happily serve a valid
35+
// gallery config. Without the SSRF guard the fetch succeeds; the
36+
// guard must block it before the request ever leaves the process.
37+
server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
38+
w.WriteHeader(http.StatusOK)
39+
_, _ = w.Write([]byte("name: internal-ssrf\nfiles: []\n"))
40+
}))
41+
})
42+
43+
AfterEach(func() {
44+
server.Close()
45+
})
46+
47+
It("blocks fetching a config from a loopback address", func() {
48+
_, err := GetGalleryConfigFromURL[ModelConfig](server.URL, "")
49+
Expect(err).To(HaveOccurred())
50+
Expect(err.Error()).To(ContainSubstring("not allowed"))
51+
})
52+
53+
It("blocks fetching a config from a loopback address (context variant)", func() {
54+
_, err := GetGalleryConfigFromURLWithContext[ModelConfig](context.Background(), server.URL, "")
55+
Expect(err).To(HaveOccurred())
56+
Expect(err.Error()).To(ContainSubstring("not allowed"))
57+
})
58+
59+
It("blocks well-known internal hostnames and metadata endpoints", func() {
60+
for _, u := range []string{
61+
"http://localhost/secret",
62+
"http://10.0.0.1/config.yaml",
63+
"http://192.168.1.1/config.yaml",
64+
"http://169.254.169.254/latest/meta-data/",
65+
} {
66+
_, err := GetGalleryConfigFromURL[ModelConfig](u, "")
67+
Expect(err).To(HaveOccurred(), "expected %s to be rejected", u)
68+
}
69+
})
70+
})
2271
})

0 commit comments

Comments
 (0)