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:
Ettore Di Giacinto committed 2026-10-20 20:54:43 +00:00
1 parent ed6e677640
commit 2de5ce7cae
3 files changed
+48 -6

No files matched your search

+7 -2
View File
@@ -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 {
+17 -4
View File
@@ -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))
})