fix(addons): take a local file only with the dialog's approval

The plain typed install and the owner's automatic upgrade never install
a package from a file. Only the host access install, which needs the
administrator's own browser session, sets an approval, so a key or a
tool cannot install a staged file. Add auth tests for the file routes.

Assisted-by: Claude:claude-sonnet-5-5 go gofmt
This commit is contained in:
Ettore Di Giacinto committed 2026-11-03 22:52:50 +00:00
1 parent 3d737a0755
commit d71df56a66
3 files changed
+143 -1

No files matched your search

@@ -0,0 +1,116 @@
// SPDX-License-Identifier: MIT
//go:build auth
package addons
import (
"bytes"
"context"
"encoding/json"
"mime/multipart"
"net/http"
"net/http/httptest"
"path/filepath"
"github.com/labstack/echo/v4"
"github.com/mudler/LocalAI/core/config"
"github.com/mudler/LocalAI/core/http/auth"
"github.com/mudler/LocalAI/core/services/addons/gateway"
"github.com/mudler/LocalAI/core/services/addons/hostgrants"
"github.com/mudler/LocalAI/core/services/addons/uploads"
"github.com/mudler/LocalAI/pkg/addons/contract"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
// The file routes with real accounts: an administrator stages with a session or
// a key, nobody else does, and a file belongs to the user that uploaded it.
var _ = Describe("add-on file routes with real sessions and API keys", func() {
It("lets administrators stage a file and keeps it from everyone else", func() {
db, err := auth.InitDB(filepath.Join(GinkgoT().TempDir(), "auth.db"))
Expect(err).NotTo(HaveOccurred())
admin := auth.User{ID: "admin", Role: auth.RoleAdmin, Status: auth.StatusActive}
other := auth.User{ID: "admin2", Role: auth.RoleAdmin, Status: auth.StatusActive}
plain := auth.User{ID: "plain", Role: auth.RoleUser, Status: auth.StatusActive}
for _, u := range []*auth.User{&admin, &other, &plain} {
Expect(db.Create(u).Error).To(Succeed())
}
adminKey, _, err := auth.CreateAPIKey(db, admin.ID, "test", auth.RoleAdmin, "secret", nil)
Expect(err).NotTo(HaveOccurred())
adminSession, err := auth.CreateSession(db, admin.ID, "secret")
Expect(err).NotTo(HaveOccurred())
otherSession, err := auth.CreateSession(db, other.ID, "secret")
Expect(err).NotTo(HaveOccurred())
plainSession, err := auth.CreateSession(db, plain.ID, "secret")
Expect(err).NotTo(HaveOccurred())
store, err := hostgrants.Open(filepath.Join(GinkgoT().TempDir(), "hostgrants.json"))
Expect(err).NotTo(HaveOccurred())
DeferCleanup(store.Close)
owner := &fileOwner{store: &uploads.Store{Root: filepath.Join(GinkgoT().TempDir(), "uploads")}, max: 1 << 20}
svc, err := gateway.NewHostGrantsOwner(gateway.HostGrantsConfig{Store: store, Manager: &hgManager{},
Admin: func(ctx context.Context, a contract.Actor) error { return auth.ValidateAddonActor(ctx, db, a, true) }})
Expect(err).NotTo(HaveOccurred())
svc.WithFiles(owner)
gw := &gateway.Gateway{HostGrantsOwner: svc, Revalidate: func(ctx context.Context, a contract.Actor) error { return auth.ValidateAddonActor(ctx, db, a, false) }}
e := echo.New()
cfg := &config.ApplicationConfig{}
cfg.Auth.APIKeyHMACSecret = "secret"
e.Use(auth.Middleware(db, cfg))
g := e.Group("/api/addons", BrowserProtection())
g.POST("/files", UploadAddonFile(gw, db))
g.GET("/files/:fileId", GetAddonFile(gw, db))
upload := func(mutate func(*http.Request)) (int, string) {
var b bytes.Buffer
mw := multipart.NewWriter(&b)
part, _ := mw.CreateFormFile("file", "x.zip")
_, _ = part.Write([]byte("zip bytes"))
Expect(mw.Close()).To(Succeed())
r := httptest.NewRequest("POST", "/api/addons/files", &b)
r.Host = "192.0.2.10:8080"
r.Header.Set("Content-Type", mw.FormDataContentType())
mutate(r)
w := httptest.NewRecorder()
e.ServeHTTP(w, r)
return w.Code, w.Body.String()
}
tab := func(session string) func(*http.Request) {
return func(r *http.Request) {
r.AddCookie(&http.Cookie{Name: "session", Value: session})
r.Header.Set("Sec-Fetch-Site", "same-origin")
}
}
key := func(r *http.Request) { r.Header.Set("Authorization", "Bearer "+adminKey) }
code, _ := upload(func(*http.Request) {})
Expect(code).To(BeElementOf(401, 403), "anonymous")
code, _ = upload(tab(plainSession))
Expect(code).To(BeElementOf(401, 403), "a user who is not an administrator")
code, _ = upload(func(r *http.Request) { tab(adminSession)(r); r.Header.Set("Sec-Fetch-Site", "cross-site") })
Expect(code).To(Equal(403), "a cross-site request that rides the session")
code, body := upload(tab(adminSession))
Expect(code).To(Equal(200), body)
code, _ = upload(key)
Expect(code).To(Equal(200), "an administrator API key may stage a file")
// A file is read by the user that uploaded it only.
var staged struct {
FileID string `json:"fileId"`
}
Expect(json.Unmarshal([]byte(body), &staged)).To(Succeed())
id := staged.FileID
Expect(id).NotTo(BeEmpty())
read := func(session string) int {
r := httptest.NewRequest("GET", "/api/addons/files/"+id, nil)
r.Host = "192.0.2.10:8080"
tab(session)(r)
w := httptest.NewRecorder()
e.ServeHTTP(w, r)
return w.Code
}
Expect(read(adminSession)).To(Equal(200))
Expect(read(otherSession)).To(Equal(404), "another administrator")
})
})
+7
View File
@@ -436,6 +436,13 @@ func (m *Manager) submit(ctx context.Context, actor contract.Actor, cmd Lifecycl
if !addonID.MatchString(cmd.AddonID) || cmd.OperationID == "" || len(cmd.OperationID) > 256 {
return OperationStatus{}, ErrConflict
}
// A package from a local file has no library and no signature behind it.
// It installs only with the approval the administrator gave in the dialog
// of their own browser session; the plain typed install, which an API key
// or a tool can make, never takes a file.
if cmd.SourceID == zipformat.SourceID && (cmd.Action == "install" || cmd.Action == "upgrade") && (cmd.Approval == nil || owner) {
return OperationStatus{}, ErrForbidden
}
if old, ok := m.journal.Operations[cmd.OperationID]; ok {
if !old.Command.same(cmd) || old.Actor != actor {
return OperationStatus{}, ErrConflict
+20 -1
View File
@@ -60,7 +60,7 @@ var _ = Describe("install from a local zip file", func() {
return r.m.FileFinish(ctx, a, id)
}
fileCmd := func(op, action string, v FileView) LifecycleCommand {
return LifecycleCommand{OperationID: op, AddonID: "sample", Action: action, SourceID: zipformat.SourceID, Version: v.FileID}
return LifecycleCommand{OperationID: op, AddonID: "sample", Action: action, SourceID: zipformat.SourceID, Version: v.FileID, Approval: &HostApproval{Digest: "sha256:" + v.SHA256}}
}
It("installs through the manager and records an unsigned local file", func() {
@@ -90,6 +90,25 @@ var _ = Describe("install from a local zip file", func() {
Expect(list[0].SourceID).To(Equal("local-file"))
})
It("never installs a file from the plain typed install, which a key or a tool can make", func() {
v, e := upload("admin", zipFor("2.0.0", "linux"))
Expect(e).NotTo(HaveOccurred())
cmd := fileCmd("p1", "install", v)
cmd.Approval = nil
_, e = r.m.Submit(ctx, actor, cmd)
Expect(e).To(MatchError(ErrForbidden))
_, e = r.m.submit(ctx, actor, fileCmd("p2", "install", v), true)
Expect(e).To(MatchError(ErrForbidden), "the automatic upgrade of the owner does not take files either")
st, _ := r.m.Status(ctx, "sample")
Expect(st.Installed).To(BeFalse())
r.m.mu.Lock()
Expect(r.m.journal.Operations).To(BeEmpty(), "a refused command leaves nothing in the journal")
r.m.mu.Unlock()
// The file is still staged for the dialog.
_, e = r.m.FileDescribe(ctx, actor, v.FileID)
Expect(e).NotTo(HaveOccurred())
})
It("keeps a file for the user that uploaded it", func() {
v, e := upload("admin", zipFor("2.0.0", "linux"))
Expect(e).NotTo(HaveOccurred())