diff --git a/core/gallery/gallery.go b/core/gallery/gallery.go index b7667b234..52ca969c8 100644 --- a/core/gallery/gallery.go +++ b/core/gallery/gallery.go @@ -15,14 +15,35 @@ import ( "github.com/mudler/LocalAI/core/config" "github.com/mudler/LocalAI/pkg/downloader" "github.com/mudler/LocalAI/pkg/system" + "github.com/mudler/LocalAI/pkg/utils" "github.com/mudler/LocalAI/pkg/xsync" "github.com/mudler/xlog" "gopkg.in/yaml.v3" ) +// validateGalleryConfigURL guards the gallery config fetch against SSRF. A +// gallery config URL can be attacker-controlled (e.g. POST /models/apply with +// an empty id fetches it directly), so a plain http(s) URL must not be allowed +// to reach private, loopback, link-local or cloud-metadata addresses. Other +// schemes (huggingface://, github:, oci://, ollama://, file://) resolve to +// fixed public services or local files and are not a network-SSRF vector, so +// they are left untouched. +// See https://github.com/mudler/LocalAI/issues/10665 +func validateGalleryConfigURL(rawURL string) error { + lower := strings.ToLower(strings.TrimSpace(rawURL)) + if strings.HasPrefix(lower, "http://") || strings.HasPrefix(lower, "https://") { + return utils.ValidateExternalURL(rawURL) + } + return nil +} + func GetGalleryConfigFromURL[T any](url string, basePath string) (T, error) { var config T + if err := validateGalleryConfigURL(url); err != nil { + xlog.Error("refusing to fetch gallery config", "error", err, "url", url) + return config, err + } uri := downloader.URI(url) err := uri.ReadWithCallback(basePath, func(url string, d []byte) error { return yaml.Unmarshal(d, &config) @@ -36,6 +57,10 @@ func GetGalleryConfigFromURL[T any](url string, basePath string) (T, error) { func GetGalleryConfigFromURLWithContext[T any](ctx context.Context, url string, basePath string) (T, error) { var config T + if err := validateGalleryConfigURL(url); err != nil { + xlog.Error("refusing to fetch gallery config", "error", err, "url", url) + return config, err + } uri := downloader.URI(url) err := uri.ReadWithAuthorizationAndCallback(ctx, basePath, "", func(url string, d []byte) error { return yaml.Unmarshal(d, &config) diff --git a/core/gallery/request_test.go b/core/gallery/request_test.go index fb1b20d16..116756965 100644 --- a/core/gallery/request_test.go +++ b/core/gallery/request_test.go @@ -1,6 +1,10 @@ package gallery_test import ( + "context" + "net/http" + "net/http/httptest" + . "github.com/mudler/LocalAI/core/gallery" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" @@ -19,4 +23,49 @@ var _ = Describe("Gallery API tests", func() { Expect(e.Name).To(Equal("gpt4all-j")) }) }) + + // SSRF guard: a user-supplied gallery config URL (e.g. POST /models/apply + // with an empty id) must not be able to reach internal network addresses. + // See https://github.com/mudler/LocalAI/issues/10665 + Context("SSRF protection on config URLs", func() { + var server *httptest.Server + + BeforeEach(func() { + // A reachable internal server that would happily serve a valid + // gallery config. Without the SSRF guard the fetch succeeds; the + // guard must block it before the request ever leaves the process. + server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte("name: internal-ssrf\nfiles: []\n")) + })) + }) + + AfterEach(func() { + server.Close() + }) + + It("blocks fetching a config from a loopback address", func() { + _, err := GetGalleryConfigFromURL[ModelConfig](server.URL, "") + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("not allowed")) + }) + + It("blocks fetching a config from a loopback address (context variant)", func() { + _, err := GetGalleryConfigFromURLWithContext[ModelConfig](context.Background(), server.URL, "") + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("not allowed")) + }) + + It("blocks well-known internal hostnames and metadata endpoints", func() { + for _, u := range []string{ + "http://localhost/secret", + "http://10.0.0.1/config.yaml", + "http://192.168.1.1/config.yaml", + "http://169.254.169.254/latest/meta-data/", + } { + _, err := GetGalleryConfigFromURL[ModelConfig](u, "") + Expect(err).To(HaveOccurred(), "expected %s to be rejected", u) + } + }) + }) })