Files
mudler--localai/core/http/route_coverage_test.go
wehub-resource-sync 1b8708893a
Security Scan / tests (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:12:26 +08:00

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.")
}
})
})