mirror of
https://github.com/mudler/LocalAI.git
synced 2026-10-28 09:08:54 -04:00
fix(addons): keep API keys off recovery mutations and same-site cookies
POST /api/addons/dispatch forwarded the recovery and update mutations to the owner for any administrator key, which skipped the browser-session rule of their own routes. Refuse those actions there. Also apply the same-origin rule to the legacy token cookie, not only the session cookie, because the global CSRF check lets same-site requests through. Do not fail the start of LocalAI when an add-on library cannot be refreshed; log it and keep the installed add-ons up. Assisted-by: Claude Code:claude-sonnet-5-5 go Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
This commit is contained in:
3 files changed
+48
-6
No files matched your search
@@ -907,8 +907,13 @@ func (a *Application) startAddons() error {
|
||||
}
|
||||
if cat.Sources != nil {
|
||||
if err = cat.Sources.RefreshEnabled(a.applicationConfig.Context); err != nil {
|
||||
_ = m.Close()
|
||||
return err
|
||||
if a.applicationConfig.Context.Err() != nil {
|
||||
_ = m.Close()
|
||||
return err
|
||||
}
|
||||
// An unreachable library must not keep LocalAI or the installed
|
||||
// add-ons down. The cache stays empty until an explicit refresh.
|
||||
xlog.Warn("Could not refresh the add-on libraries at startup", "error", err)
|
||||
}
|
||||
}
|
||||
if c.InstanceID == c.OwnerID {
|
||||
|
||||
@@ -188,6 +188,13 @@ func Dispatch(g *gateway.Gateway, db *gorm.DB, opts ...Option) echo.HandlerFunc
|
||||
if decode(c, &r) != nil {
|
||||
return failure(c, 400, "Invalid addon request")
|
||||
}
|
||||
// Recovery and update mutations have their own routes, which accept
|
||||
// only the administrator's own browser session. The generic dispatch
|
||||
// also takes API keys, so it must not carry them.
|
||||
if (gateway.RecoveryAction(r.Action) && r.Action != gateway.ActionRecoveryStatus) ||
|
||||
(gateway.UpdateAction(r.Action) && r.Action != gateway.ActionUpdateStatus) {
|
||||
return failure(c, 403, "Addon request denied")
|
||||
}
|
||||
if r.Action != "read" {
|
||||
if e = administrator(c, db, a, s); e != nil {
|
||||
if errors.Is(e, gateway.ErrUnavailable) {
|
||||
@@ -254,10 +261,16 @@ func Relay(g *gateway.Gateway, db *gorm.DB, opts ...Option) echo.HandlerFunc {
|
||||
func BrowserProtection() echo.MiddlewareFunc {
|
||||
return func(next echo.HandlerFunc) echo.HandlerFunc {
|
||||
return func(c echo.Context) error {
|
||||
if _, err := c.Cookie("session"); err == nil {
|
||||
if c.Request().Header.Get("Sec-Fetch-Site") != "same-origin" {
|
||||
return failure(c, 403, "Same-origin browser request required")
|
||||
}
|
||||
h := c.Request().Header
|
||||
_, session := c.Cookie("session")
|
||||
// The legacy token cookie carries an API key. It is a browser
|
||||
// credential too, unless a header credential is sent with it.
|
||||
_, token := c.Cookie("token")
|
||||
headerCredential := h.Get("Authorization") != "" || h.Get("X-Api-Key") != "" || h.Get("Xi-Api-Key") != ""
|
||||
if (session == nil || (token == nil && !headerCredential)) && h.Get("Sec-Fetch-Site") != "same-origin" {
|
||||
// The global CSRF check lets same-site requests through, so
|
||||
// a sibling origin could drive an administrator's cookie.
|
||||
return failure(c, 403, "Same-origin browser request required")
|
||||
}
|
||||
return next(c)
|
||||
}
|
||||
|
||||
@@ -46,10 +46,34 @@ var _ = Describe("authenticated addon HTTP", func() {
|
||||
e.ServeHTTP(w, r)
|
||||
return w.Code
|
||||
}
|
||||
// Recovery and update mutations are not reachable through the generic
|
||||
// dispatch with a key, only through their own browser-session routes.
|
||||
for _, action := range []string{"recovery-restore", "recovery-purge", "recovery-backup", "update-upgrade", "update-check"} {
|
||||
r := httptest.NewRequest("POST", "/api/addons/dispatch", bytes.NewBufferString(`{"addonId":"sample","operationId":"op","generation":1,"action":"`+action+`","arguments":{}}`))
|
||||
r.Header.Set("Content-Type", "application/json")
|
||||
r.Header.Set("Authorization", "Bearer "+token)
|
||||
w := httptest.NewRecorder()
|
||||
e.ServeHTTP(w, r)
|
||||
Expect(w.Code).To(Equal(403), action)
|
||||
}
|
||||
Expect(call(false, "cross-site")).To(Equal(503))
|
||||
Expect(call(true, "cross-site")).To(Equal(403))
|
||||
Expect(call(true, "")).To(Equal(403))
|
||||
Expect(call(true, "same-origin")).To(Equal(503))
|
||||
// The legacy token cookie is a browser credential too: a same-site
|
||||
// request from a sibling origin must not drive it.
|
||||
tokenCall := func(site string) int {
|
||||
r := httptest.NewRequest("POST", "/api/addons/dispatch", bytes.NewBufferString(`{"addonId":"sample","operationId":"op","generation":1,"action":"enable","arguments":{}}`))
|
||||
r.Header.Set("Content-Type", "application/json")
|
||||
r.Header.Set("Sec-Fetch-Site", site)
|
||||
r.AddCookie(&http.Cookie{Name: "token", Value: token})
|
||||
w := httptest.NewRecorder()
|
||||
e.ServeHTTP(w, r)
|
||||
return w.Code
|
||||
}
|
||||
Expect(tokenCall("same-site")).To(Equal(403))
|
||||
Expect(tokenCall("")).To(Equal(403))
|
||||
Expect(tokenCall("same-origin")).To(Equal(503))
|
||||
Expect(db.Model(&u).Update("role", auth.RoleUser).Error).To(Succeed())
|
||||
Expect(call(false, "")).To(Equal(401))
|
||||
})
|
||||
|
||||
Reference in new issue
Block a user