diff --git a/core/application/application.go b/core/application/application.go index 5e1d3915f..e0a4a9178 100644 --- a/core/application/application.go +++ b/core/application/application.go @@ -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 { diff --git a/core/http/endpoints/addons/gateway.go b/core/http/endpoints/addons/gateway.go index edd5be012..74d464dff 100644 --- a/core/http/endpoints/addons/gateway.go +++ b/core/http/endpoints/addons/gateway.go @@ -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) } diff --git a/core/http/endpoints/addons/gateway_auth_test.go b/core/http/endpoints/addons/gateway_auth_test.go index 346680993..16ee7a636 100644 --- a/core/http/endpoints/addons/gateway_auth_test.go +++ b/core/http/endpoints/addons/gateway_auth_test.go @@ -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)) })