mirror of
https://github.com/mudler/LocalAI.git
synced 2026-11-06 04:23:15 -05:00
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:
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")
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
|
||||
@@ -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())
|
||||
|
||||
Reference in new issue
Block a user