fix(backends): refuse foreign model loads in opus and local-store (#10769)

When a model config has no explicit backend, the model loader greedily
probes every installed backend and binds to the first Load that
succeeds. opus and local-store were the only in-tree backends with no
model artefact to validate, so they accepted anything — an LLM
installed after them could silently bind to the audio codec or the
vector store and then fail at inference with "unimplemented"
(see #9287).

opus now accepts only its own name (what the realtime WebRTC path
sends) or none. local-store namespaces are arbitrary (router caches,
biometrics, user-named stores), so core's StoreBackend now marks
genuine store loads with a store:// prefix on the gRPC model name and
the backend refuses names without it; core and backend ship from the
same release, so the convention upgrades in lockstep.

Also repair the bit-rotted 'make test-stores' bootstrap (the suite
never registered external backends, so BACKENDS_PATH was dead weight)
and add the Load-validation rule to the adding-backends checklist.

Related: #9287

Assisted-by: Claude:claude-fable-5 golangci-lint

Signed-off-by: Richard Palethorpe <io@richiejp.com>
This commit is contained in:
Richard Palethorpe
2026-07-11 08:17:34 +01:00
committed by GitHub
parent 23a044ee0b
commit 1f9fda7138
8 changed files with 67 additions and 9 deletions

View File

@@ -251,6 +251,7 @@ After adding a new backend, verify:
- [ ] No YAML syntax errors (check with linter)
- [ ] No Makefile syntax errors (check with linter)
- [ ] Follows the same pattern as similar backends (e.g., if it's a transcription backend, follow `faster-whisper` pattern)
- [ ] **`Load` validates its input and refuses models it can't serve.** When a model config has no explicit `backend:`, the model loader greedily probes *every* installed backend with the model's name and binds to the first `Load` that succeeds — an accept-anything `Load` will capture arbitrary LLMs (issue #9287). Backends that load a real artefact get this for free (the load fails); backends with no artefact must gate on the name: `opus` accepts only its own name (or none), `local-store` requires the `store.NamespacePrefix` namespace marker sent by `core/backend/stores.go`.
- [ ] Documented: added to the category list in `docs/content/features/backends.md` (and any new endpoint/realtime capability documented under `docs/content/`)
- [ ] If it is an in-house native C/C++/GGML engine, added to the maintained-engines table in the top-level `README.md`

View File

@@ -22,6 +22,7 @@ import (
"fmt"
"math"
"slices"
"strings"
"github.com/mudler/LocalAI/pkg/grpc/base"
pb "github.com/mudler/LocalAI/pkg/grpc/proto"
@@ -57,12 +58,18 @@ func NewStore() *Store {
}
}
// Load is a no-op — local-store has no on-disk artefact. opts.Model is
// just a namespace identifier; isolation is already handled upstream
// (ModelLoader spawns a fresh local-store process per (backend,
// model) tuple, so each namespace is its own Store{} instance).
// Load only validates the namespace — local-store has no on-disk
// artefact. opts.Model is a namespace identifier, which core's
// StoreBackend always sends with store.NamespacePrefix; anything else
// is the model loader's greedy autoload probing with a real model name,
// which must be refused or the LLM binds to the vector store. Isolation
// is already handled upstream (ModelLoader spawns a fresh local-store
// process per (backend, model) tuple, so each namespace is its own
// Store{} instance).
func (s *Store) Load(opts *pb.ModelOptions) error {
_ = opts
if !strings.HasPrefix(opts.GetModel(), store.NamespacePrefix) {
return fmt.Errorf("local-store: refusing to load %q: not a store namespace (expected %q prefix)", opts.GetModel(), store.NamespacePrefix)
}
return nil
}

View File

@@ -12,6 +12,7 @@ import (
"testing"
pb "github.com/mudler/LocalAI/pkg/grpc/proto"
"github.com/mudler/LocalAI/pkg/store"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
@@ -186,8 +187,18 @@ var _ = Describe("StoresFind", func() {
})
var _ = Describe("StoresLoad", func() {
It("is a no-op", func() {
Expect(NewStore().Load(&pb.ModelOptions{Model: "any-namespace"})).To(Succeed())
It("accepts prefixed store namespaces", func() {
Expect(NewStore().Load(&pb.ModelOptions{Model: store.NamespacePrefix + "any-namespace"})).To(Succeed())
})
It("accepts the prefix alone (default store)", func() {
Expect(NewStore().Load(&pb.ModelOptions{Model: store.NamespacePrefix})).To(Succeed())
})
It("refuses model names without the namespace prefix", func() {
err := NewStore().Load(&pb.ModelOptions{Model: "some-llm.gguf"})
Expect(err).To(MatchError(ContainSubstring("not a store namespace")))
Expect(NewStore().Load(&pb.ModelOptions{})).NotTo(Succeed())
})
})

View File

@@ -34,7 +34,15 @@ type Opus struct {
decoders map[string]*cachedDecoder
}
// Load accepts only the codec's own name (what the realtime WebRTC path
// sends) or no name at all — there is no model artefact here, so without
// this check the model loader's greedy autoload (which probes every
// installed backend with real model names) would happily bind an LLM to
// the audio codec.
func (o *Opus) Load(opts *pb.ModelOptions) error {
if m := opts.GetModel(); m != "" && m != "opus" {
return fmt.Errorf("opus: refusing to load %q: opus is an audio codec, not a model backend", m)
}
o.decoders = make(map[string]*cachedDecoder)
go o.evictLoop()
return Init()

View File

@@ -304,6 +304,17 @@ func computeTHD(samples []int16, fundamentalHz float64, sampleRate, numHarmonics
// --- Opus specs ---
var _ = Describe("Opus Load", func() {
It("accepts its own name (the realtime WebRTC path)", func() {
Expect((&Opus{}).Load(&pb.ModelOptions{Model: "opus"})).To(Succeed())
})
It("refuses foreign model names from the greedy autoload", func() {
err := (&Opus{}).Load(&pb.ModelOptions{Model: "some-llm.gguf"})
Expect(err).To(MatchError(ContainSubstring("audio codec")))
})
})
var _ = Describe("Opus", func() {
var o *Opus

View File

@@ -133,10 +133,16 @@ func StoreBackend(sl *model.ModelLoader, appConfig *config.ApplicationConfig, st
// Try to add key with length N when existing length is M
// Use the store namespace as modelID so each namespace gets its own
// process instance and its own in-memory Store{}.
//
// The model name sent over gRPC carries store.NamespacePrefix so the
// backend can tell a genuine store load from the greedy autoload
// probing it with LLM model names; local-store refuses names without
// the prefix (core and backend ship from the same release, so the
// convention upgrades in lockstep).
sc := []model.Option{
model.WithBackendString(backend),
model.WithModelID(storeName),
model.WithModel(storeName),
model.WithModel(store.NamespacePrefix + storeName),
}
return sl.Load(sc...)

View File

@@ -10,6 +10,14 @@ import (
// Wrapper for the GRPC client so that simple use cases are handled without verbosity
// NamespacePrefix marks a load request as a store namespace rather than a
// model artefact. Core's StoreBackend prepends it to the namespace it sends
// as the gRPC model name, and the local-store backend refuses loads without
// it — otherwise the model loader's greedy autoload (which probes every
// installed backend with LLM model names) would bind arbitrary models to
// the vector store, since store loads have no artefact to validate.
const NamespacePrefix = "store://"
// SetCols sets multiple key-value pairs in the store
// It's in columnar format so that keys[i] is associated with values[i]
func SetCols(ctx context.Context, c grpc.Backend, keys [][]float32, values [][]byte) error {

View File

@@ -11,6 +11,7 @@ import (
. "github.com/onsi/gomega"
"github.com/mudler/LocalAI/core/config"
"github.com/mudler/LocalAI/core/gallery"
"github.com/mudler/LocalAI/pkg/grpc"
"github.com/mudler/LocalAI/pkg/model"
"github.com/mudler/LocalAI/pkg/store"
@@ -52,15 +53,20 @@ var _ = Describe("Integration tests for the stores backend(s) and internal APIs"
storeOpts := []model.Option{
model.WithBackendString(bc.Backend),
model.WithModel("test"),
model.WithModel(store.NamespacePrefix + "test"),
}
// External backends are normally registered at app startup
// (gallery.RegisterBackends); a bare ModelLoader knows none,
// so wire up the BACKENDS_PATH the Makefile builds into.
systemState, err := system.GetSystemState(
system.WithModelPath(tmpdir),
system.WithBackendPath(os.Getenv("BACKENDS_PATH")),
)
Expect(err).ToNot(HaveOccurred())
sl = model.NewModelLoader(systemState)
Expect(gallery.RegisterBackends(systemState, sl)).To(Succeed())
sc, err = sl.Load(storeOpts...)
Expect(err).ToNot(HaveOccurred())
Expect(sc).ToNot(BeNil())