diff --git a/core/http/auth/middleware_test.go b/core/http/auth/middleware_test.go index a7dc58560..bdfadaafe 100644 --- a/core/http/auth/middleware_test.go +++ b/core/http/auth/middleware_test.go @@ -73,6 +73,10 @@ var _ = Describe("Auth Middleware", func() { method string path string }{ + // No OPTIONS entry here: CORS preflights are exempt from auth on + // every path by design (see publicRouteRegistry, #4576), so there is + // no "private OPTIONS lookalike" left to assert. Near-prefix privacy + // for /api/auth/ is still pinned by the non-OPTIONS entries below. {http.MethodPost, "/api/instructions"}, {http.MethodGet, "/api/instructions-private"}, {http.MethodPost, "/swagger"}, @@ -82,7 +86,6 @@ var _ = Describe("Auth Middleware", func() { {http.MethodGet, "/api/auth/token-login"}, {http.MethodGet, "/api/auth/register"}, {http.MethodGet, "/api/auth/private"}, - {http.MethodOptions, "/api/auth-private/resource"}, {http.MethodPost, "/app/settings"}, {http.MethodGet, "/app-private"}, {http.MethodPost, "/browse/models"}, @@ -728,4 +731,31 @@ var _ = Describe("Auth Middleware", func() { Expect(p.key).To(BeNil()) }) }) + + Context("CORS preflight (OPTIONS) bypasses API-key auth", func() { + // Regression for #4576: an OPTIONS preflight cannot carry credentials + // by HTTP spec, so it must not be gated on API-key auth. Without the + // bypass the auth middleware answers 401 before the CORS middleware + // (registered after auth in app.go) can answer the preflight, and + // browsers block the actual cross-origin API call. + var app *echo.Echo + BeforeEach(func() { + appConfig := config.NewApplicationConfig() + appConfig.ApiKeys = []string{"legacy-secret"} + app = echo.New() + app.Use(auth.Middleware(nil, appConfig)) + app.OPTIONS("/v1/models", ok) + app.GET("/v1/models", ok) + }) + + It("OPTIONS preflight returns 200 without an API key", func() { + rec := doRequest(app, http.MethodOptions, "/v1/models") + Expect(rec.Code).To(Equal(http.StatusOK), "OPTIONS preflight must bypass API-key auth") + }) + + It("GET without an API key is still 401 (auth otherwise enforced)", func() { + rec := doRequest(app, http.MethodGet, "/v1/models") + Expect(rec.Code).To(Equal(http.StatusUnauthorized)) + }) + }) }) diff --git a/core/http/auth/public_routes.go b/core/http/auth/public_routes.go index 658205a78..c2509e9a9 100644 --- a/core/http/auth/public_routes.go +++ b/core/http/auth/public_routes.go @@ -34,7 +34,13 @@ var publicRouteRegistry = []publicRouteRule{ {Method: http.MethodGet, Path: "/api/auth/github/callback"}, {Method: http.MethodGet, Path: "/api/auth/oidc/login"}, {Method: http.MethodGet, Path: "/api/auth/oidc/callback"}, - {Method: http.MethodOptions, Path: "/api/auth/", Prefix: true}, + + // CORS preflight. An OPTIONS request cannot carry credentials by HTTP + // spec, so preflights targeting any endpoint must not be gated on auth; + // the CORS middleware (registered after auth in app.go) answers them. + // This rule also covers the auth-bootstrap preflights the previous + // OPTIONS-under-/api/auth/ rule existed for. See #4576. + {Method: http.MethodOptions, Path: "/", Prefix: true}, // SPA. {Method: http.MethodGet, Path: "/"}, diff --git a/core/http/route_coverage_test.go b/core/http/route_coverage_test.go index 9267cff73..65dfe37f4 100644 --- a/core/http/route_coverage_test.go +++ b/core/http/route_coverage_test.go @@ -149,10 +149,12 @@ var _ = Describe("Route auth coverage", func() { return true } - // CORS preflight may be represented as a route by some Echo - // configurations. The method restriction keeps the rest of the auth - // namespace private. - return method == http.MethodOptions && strings.HasPrefix(path, "/api/auth/") + // CORS preflight: OPTIONS requests are exempt from auth on every + // path (publicRouteRegistry, #4576) — a preflight cannot carry + // credentials, and the CORS middleware answers it without granting + // any API access. Echo may register such routes explicitly (e.g. + // /api/cors-proxy's preflight handler). + return method == http.MethodOptions } leaks := []string{} diff --git a/docs/content/features/authentication.md b/docs/content/features/authentication.md index c713a0e9d..9cf758b95 100644 --- a/docs/content/features/authentication.md +++ b/docs/content/features/authentication.md @@ -54,7 +54,7 @@ LocalAI also permits the requests needed for health checks, credential acquisiti - Local registration and login: `POST /api/auth/register` and `POST /api/auth/login`. - GitHub OAuth: `GET /api/auth/github/login` and `GET /api/auth/github/callback`. - OIDC: `GET /api/auth/oidc/login` and `GET /api/auth/oidc/callback`. -- Authentication preflight requests: `OPTIONS` under `/api/auth/`. +- CORS preflight requests: `OPTIONS` on every path. A cross-origin preflight cannot carry credentials by HTTP spec, so these requests are never gated on auth; the CORS middleware answers them, which grants no API access. - SPA shell routes: `GET /`, `HEAD /`, and `GET` requests at `/app`, `/browse`, `/login`, `/invite/*`, and `/explorer`. Subpaths under `/app/` and `/browse/` are also available through `GET`. - SPA assets: `GET /favicon.svg` and `GET` requests under `/assets/`, `/locales/`, and `/static/`. - Branding reads: `GET /api/branding` and `GET` requests under `/branding/asset/`. Branding mutations still require admin credentials.