226 lines
6.4 KiB
Go
226 lines
6.4 KiB
Go
//go:build auth
|
|
|
|
package http_test
|
|
|
|
import (
|
|
"context"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"os"
|
|
"path/filepath"
|
|
"regexp"
|
|
"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 API-prefixed 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("rejects anonymous traffic on every API route except the explicit allowlist", func() {
|
|
// Routes that are intentionally reachable without authentication.
|
|
// Each entry needs a justification comment.
|
|
expectedPublicPrefixes := []string{
|
|
// Auth flow itself — login, registration, OAuth callbacks
|
|
"/api/auth/",
|
|
|
|
// Distributed-mode node self-service: authenticated by a
|
|
// registration token presented in the request body, not by the
|
|
// global auth middleware. Verified separately in node tests.
|
|
"/api/node/register",
|
|
|
|
// Branding read for login screen + public branding asset server.
|
|
// Mutating /api/branding/asset/* routes are exempt from global
|
|
// auth but admin-gated by route-level middleware
|
|
// (TestBrandingRoutes_AdminGatingHolds pins that contract).
|
|
"/api/branding",
|
|
"/branding/",
|
|
|
|
// Health and metadata used by orchestrators / load balancers
|
|
"/healthz",
|
|
"/readyz",
|
|
|
|
// Static asset surfaces used by the UI before login
|
|
"/favicon.svg",
|
|
"/static/",
|
|
"/assets/",
|
|
"/locales/",
|
|
"/generated-audio/",
|
|
"/generated-images/",
|
|
"/generated-videos/",
|
|
}
|
|
expectedPublicExact := map[string]bool{
|
|
// SPA shell + redirects — UI handles login client-side
|
|
"/": true,
|
|
"/app": true,
|
|
"/browse": true,
|
|
"/swagger": true,
|
|
"/swagger/": true,
|
|
"/swagger/*": true,
|
|
"/oauth/start": true,
|
|
}
|
|
|
|
// Per-route exemptions for distributed-mode node endpoints whose
|
|
// pattern carries a path param (so they don't fit a flat prefix).
|
|
// These authenticate via registration token at the handler layer.
|
|
nodeSelfPattern := regexp.MustCompile(`^/api/node/[^/]+/(heartbeat|drain|deregister)$`)
|
|
|
|
// 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, "/")
|
|
}
|
|
|
|
isAPI := func(p string) bool {
|
|
apiPrefixes := []string{
|
|
"/api/", "/v1/", "/models/", "/backends/", "/backend/",
|
|
"/tts", "/vad", "/video", "/stores/", "/system",
|
|
"/ws/", "/generated-", "/chat/", "/completions",
|
|
"/edits", "/embeddings", "/audio/", "/images/",
|
|
"/messages", "/responses",
|
|
}
|
|
if p == "/metrics" {
|
|
return true
|
|
}
|
|
for _, pre := range apiPrefixes {
|
|
if strings.HasPrefix(p, pre) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
isAllowlisted := func(p string) bool {
|
|
if expectedPublicExact[p] {
|
|
return true
|
|
}
|
|
for _, pre := range expectedPublicPrefixes {
|
|
if strings.HasPrefix(p, pre) {
|
|
return true
|
|
}
|
|
}
|
|
if nodeSelfPattern.MatchString(p) {
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
var leaks []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
|
|
|
|
// Only inspect API surface — UI/static paths are intentionally
|
|
// reachable for SPA hydration before login.
|
|
if !isAPI(r.Path) {
|
|
continue
|
|
}
|
|
if isAllowlisted(r.Path) {
|
|
continue
|
|
}
|
|
|
|
req := httptest.NewRequest(r.Method, concretize(r.Path), nil)
|
|
req.Header.Set("Content-Type", "application/json")
|
|
rec := httptest.NewRecorder()
|
|
app.ServeHTTP(rec, req)
|
|
|
|
// We accept:
|
|
// 401: middleware rejected — the desired outcome
|
|
// 404: route not actually reachable in this minimal config
|
|
// (e.g. distributed-only routes); not an auth leak
|
|
// 405: method not allowed; auth never ran but isn't a leak
|
|
if rec.Code == http.StatusUnauthorized ||
|
|
rec.Code == http.StatusNotFound ||
|
|
rec.Code == http.StatusMethodNotAllowed {
|
|
continue
|
|
}
|
|
|
|
leaks = append(leaks, " "+r.Method+" "+r.Path+
|
|
" → "+http.StatusText(rec.Code)+
|
|
" (got "+strconv.Itoa(rec.Code)+")")
|
|
}
|
|
|
|
if len(leaks) > 0 {
|
|
Fail("Routes reachable without authentication:\n" +
|
|
strings.Join(leaks, "\n") +
|
|
"\n\nIf the route is intentionally public, add it to " +
|
|
"expectedPublicPrefixes or expectedPublicExact in " +
|
|
"core/http/route_coverage_test.go with a justification " +
|
|
"comment. Otherwise, gate it behind the auth middleware " +
|
|
"(automatic for /api/, /v1/, /models/, /backends/, etc.) " +
|
|
"or RequireAdmin / RequireFeature.")
|
|
}
|
|
})
|
|
})
|