mirror of
https://github.com/mudler/LocalAI.git
synced 2026-09-12 22:33:54 -04:00
* fix(auth): bypass API-key auth for CORS preflight (OPTIONS) requests When API-key auth is enabled, a browser making a cross-origin API call first sends an OPTIONS CORS preflight, which cannot carry credentials by HTTP spec. The auth middleware is registered (app.go:324) before the CORS middleware (app.go:337-347), so the preflight hit auth first and returned 401 before the CORS middleware could answer it, blocking the actual call. Bypass auth for OPTIONS so the request reaches the CORS middleware, which answers the preflight with 200 + headers. Real API requests (GET/POST/etc.) still require auth. Regression test added (red on master, green on branch). Refs #4576 Signed-off-by: supermario_leo <leo.stack@outlook.com> * fix(auth): exempt CORS preflights via publicRouteRegistry instead of middleware bypass Route the global OPTIONS exemption through publicRouteRegistry (OPTIONS on every path, replacing the OPTIONS-under-/api/auth/ rule it subsumes) instead of a hardcoded method check inside Middleware, so "which requests skip auth" has one mechanism. Preflights now flow through the same authenticate-then-public-rules path as other public routes, which also lets a credentialed OPTIONS request keep its user context. Update the route-coverage allowlist and the near-prefix lookalike table for the new semantics (OPTIONS is public on every path by design; near-prefix privacy stays pinned by the non-OPTIONS entries), and fix the authentication docs' exempt-route enumeration, which still described OPTIONS as an /api/auth/-only exemption. Signed-off-by: supermario_leo <leo.stack@outlook.com> --------- Signed-off-by: supermario_leo <leo.stack@outlook.com>
205 lines
7.0 KiB
Go
205 lines
7.0 KiB
Go
//go:build auth
|
|
|
|
package http_test
|
|
|
|
import (
|
|
"context"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"os"
|
|
"path/filepath"
|
|
"strconv"
|
|
"strings"
|
|
|
|
"github.com/labstack/echo/v4"
|
|
"github.com/mudler/LocalAI/core/application"
|
|
"github.com/mudler/LocalAI/core/config"
|
|
. "github.com/mudler/LocalAI/core/http"
|
|
"github.com/mudler/LocalAI/pkg/system"
|
|
|
|
. "github.com/onsi/ginkgo/v2"
|
|
. "github.com/onsi/gomega"
|
|
)
|
|
|
|
// Every route registered by API() must either reject anonymous traffic with
|
|
// 401 or appear on the explicit public allowlist below. The test fails on
|
|
// routes that ship without an auth decision; adding a new public surface
|
|
// should be deliberate, not a side effect.
|
|
var _ = Describe("Route auth coverage", func() {
|
|
var (
|
|
app *echo.Echo
|
|
tmpdir string
|
|
c context.Context
|
|
cancel context.CancelFunc
|
|
appInst *application.Application
|
|
)
|
|
|
|
BeforeEach(func() {
|
|
var err error
|
|
tmpdir, err = os.MkdirTemp("", "route-coverage-")
|
|
Expect(err).ToNot(HaveOccurred())
|
|
|
|
modelDir := filepath.Join(tmpdir, "models")
|
|
Expect(os.Mkdir(modelDir, 0750)).To(Succeed())
|
|
bDir := filepath.Join(tmpdir, "backends")
|
|
Expect(os.Mkdir(bDir, 0750)).To(Succeed())
|
|
|
|
c, cancel = context.WithCancel(context.Background())
|
|
|
|
systemState, err := system.GetSystemState(
|
|
system.WithBackendPath(bDir),
|
|
system.WithModelPath(modelDir),
|
|
)
|
|
Expect(err).ToNot(HaveOccurred())
|
|
|
|
// Auth enabled, no legacy keys, no admin user pre-created. With auth
|
|
// enabled the global middleware MUST reject anonymous API requests
|
|
// regardless of admin presence.
|
|
appInst, err = application.New(
|
|
config.WithContext(c),
|
|
config.WithSystemState(systemState),
|
|
config.WithAuthEnabled(true),
|
|
config.WithAuthDatabaseURL(":memory:"),
|
|
config.WithAuthAPIKeyHMACSecret("test-secret-for-route-coverage"),
|
|
)
|
|
Expect(err).ToNot(HaveOccurred())
|
|
|
|
app, err = API(appInst)
|
|
Expect(err).ToNot(HaveOccurred())
|
|
})
|
|
|
|
AfterEach(func() {
|
|
cancel()
|
|
Expect(os.RemoveAll(tmpdir)).To(Succeed())
|
|
})
|
|
|
|
It("enforces the anonymous-access decision for every registered route", func() {
|
|
type routePattern struct {
|
|
method string
|
|
path string
|
|
}
|
|
|
|
// This allowlist deliberately restates the public contract instead of
|
|
// importing the production registry, so drift in either direction fails.
|
|
expectedPublicRoutes := map[routePattern]struct{}{
|
|
// Discovery used before clients have credentials.
|
|
{method: http.MethodGet, path: "/.well-known/localai.json"}: {},
|
|
{method: http.MethodGet, path: "/api/instructions"}: {},
|
|
{method: http.MethodGet, path: "/api/instructions/:name"}: {},
|
|
{method: http.MethodGet, path: "/swagger"}: {},
|
|
{method: http.MethodGet, path: "/swagger/"}: {},
|
|
{method: http.MethodGet, path: "/swagger/index.html"}: {},
|
|
{method: http.MethodGet, path: "/swagger/*"}: {},
|
|
|
|
// Orchestrator health probes.
|
|
{method: http.MethodGet, path: "/healthz"}: {},
|
|
{method: http.MethodGet, path: "/readyz"}: {},
|
|
|
|
// Authentication bootstrap endpoints only; authenticated account and
|
|
// admin operations under /api/auth/ remain protected.
|
|
{method: http.MethodGet, path: "/api/auth/status"}: {},
|
|
{method: http.MethodPost, path: "/api/auth/token-login"}: {},
|
|
{method: http.MethodPost, path: "/api/auth/register"}: {},
|
|
{method: http.MethodPost, path: "/api/auth/login"}: {},
|
|
{method: http.MethodGet, path: "/api/auth/github/login"}: {},
|
|
{method: http.MethodGet, path: "/api/auth/github/callback"}: {},
|
|
{method: http.MethodGet, path: "/api/auth/oidc/login"}: {},
|
|
{method: http.MethodGet, path: "/api/auth/oidc/callback"}: {},
|
|
|
|
// SPA shell and client-side navigation before login.
|
|
{method: http.MethodGet, path: "/"}: {},
|
|
{method: http.MethodHead, path: "/"}: {},
|
|
{method: http.MethodGet, path: "/app"}: {},
|
|
{method: http.MethodGet, path: "/app/*"}: {},
|
|
{method: http.MethodGet, path: "/browse"}: {},
|
|
{method: http.MethodGet, path: "/browse/*"}: {},
|
|
{method: http.MethodGet, path: "/login"}: {},
|
|
{method: http.MethodGet, path: "/invite/:code"}: {},
|
|
{method: http.MethodGet, path: "/explorer"}: {},
|
|
|
|
// Static assets needed to render the pre-authentication UI.
|
|
{method: http.MethodGet, path: "/favicon.svg"}: {},
|
|
{method: http.MethodGet, path: "/assets/*"}: {},
|
|
{method: http.MethodGet, path: "/locales/*"}: {},
|
|
{method: http.MethodGet, path: "/static/*"}: {},
|
|
|
|
// Branding reads used by the login screen. Branding mutations are
|
|
// intentionally absent and must receive 401.
|
|
{method: http.MethodGet, path: "/api/branding"}: {},
|
|
{method: http.MethodGet, path: "/branding/asset/:kind"}: {},
|
|
}
|
|
|
|
// Concretize a route pattern into a URL suitable for httptest.
|
|
// Echo path params come back as ":name" and wildcards as "*".
|
|
concretize := func(pattern string) string {
|
|
parts := strings.Split(pattern, "/")
|
|
for i, p := range parts {
|
|
if strings.HasPrefix(p, ":") {
|
|
parts[i] = "test"
|
|
} else if p == "*" {
|
|
parts[i] = "test"
|
|
}
|
|
}
|
|
return strings.Join(parts, "/")
|
|
}
|
|
|
|
isAllowlisted := func(method, path string) bool {
|
|
_, ok := expectedPublicRoutes[routePattern{method: method, path: path}]
|
|
if ok {
|
|
return true
|
|
}
|
|
|
|
// 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{}
|
|
blockedPublicRoutes := []string{}
|
|
seen := map[string]bool{}
|
|
for _, r := range app.Routes() {
|
|
// Echo registers automatic HEAD routes for GETs; auth check is
|
|
// identical, so dedupe.
|
|
key := r.Method + " " + r.Path
|
|
if seen[key] {
|
|
continue
|
|
}
|
|
seen[key] = true
|
|
|
|
req := httptest.NewRequest(r.Method, concretize(r.Path), nil)
|
|
req.Header.Set("Content-Type", "application/json")
|
|
rec := httptest.NewRecorder()
|
|
app.ServeHTTP(rec, req)
|
|
|
|
if isAllowlisted(r.Method, r.Path) {
|
|
if rec.Code == http.StatusUnauthorized {
|
|
blockedPublicRoutes = append(blockedPublicRoutes, " "+r.Method+" "+r.Path)
|
|
}
|
|
continue
|
|
}
|
|
|
|
if rec.Code == http.StatusUnauthorized {
|
|
continue
|
|
}
|
|
|
|
leaks = append(leaks, " "+r.Method+" "+r.Path+
|
|
" → "+http.StatusText(rec.Code)+
|
|
" (got "+strconv.Itoa(rec.Code)+")")
|
|
}
|
|
|
|
if len(leaks) > 0 || len(blockedPublicRoutes) > 0 {
|
|
Fail("Routes reachable without authentication:\n" +
|
|
strings.Join(leaks, "\n") +
|
|
"\n\nPublic routes unexpectedly requiring authentication:\n" +
|
|
strings.Join(blockedPublicRoutes, "\n") +
|
|
"\n\nIf a route is intentionally public, add its exact method and Echo " +
|
|
"pattern to expectedPublicRoutes in core/http/route_coverage_test.go " +
|
|
"with a justification comment. Otherwise, keep it behind the " +
|
|
"global auth middleware or RequireAdmin / RequireFeature.")
|
|
}
|
|
})
|
|
})
|