Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion core/cli/run.go
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ type RunCMD struct {
UseSubtleKeyComparison bool `env:"LOCALAI_SUBTLE_KEY_COMPARISON" default:"false" help:"If true, API Key validation comparisons will be performed using constant-time comparisons rather than simple equality. This trades off performance on each request for resiliancy against timing attacks." group:"hardening"`
DisableApiKeyRequirementForHttpGet bool `env:"LOCALAI_DISABLE_API_KEY_REQUIREMENT_FOR_HTTP_GET" default:"false" help:"If true, a valid API key is not required to issue GET requests to portions of the web ui. This should only be enabled in secure testing environments" group:"hardening"`
DisableMetricsEndpoint bool `env:"LOCALAI_DISABLE_METRICS_ENDPOINT,DISABLE_METRICS_ENDPOINT" default:"false" help:"Disable the /metrics endpoint" group:"api"`
HttpGetExemptedEndpoints []string `env:"LOCALAI_HTTP_GET_EXEMPTED_ENDPOINTS" default:"^/$,^/browse/?$,^/talk/?$,^/p2p/?$,^/chat/?$,^/image/?$,^/text2image/?$,^/tts/?$,^/static/.*$,^/swagger.*$" help:"If LOCALAI_DISABLE_API_KEY_REQUIREMENT_FOR_HTTP_GET is overriden to true, this is the list of endpoints to exempt. Only adjust this in case of a security incident or as a result of a personal security posture review" group:"hardening"`
HttpGetExemptedEndpoints []string `env:"LOCALAI_HTTP_GET_EXEMPTED_ENDPOINTS" default:"^/$,^/app(/.*)?$,^/browse(/.*)?$,^/login/?$,^/explorer/?$,^/assets/.*$,^/static/.*$,^/swagger.*$" help:"If LOCALAI_DISABLE_API_KEY_REQUIREMENT_FOR_HTTP_GET is overriden to true, this is the list of endpoints to exempt. Only adjust this in case of a security incident or as a result of a personal security posture review" group:"hardening"`
Peer2Peer bool `env:"LOCALAI_P2P,P2P" name:"p2p" default:"false" help:"Enable P2P mode" group:"p2p"`
Peer2PeerDHTInterval int `env:"LOCALAI_P2P_DHT_INTERVAL,P2P_DHT_INTERVAL" default:"360" name:"p2p-dht-interval" help:"Interval for DHT refresh (used during token generation)" group:"p2p"`
Peer2PeerOTPInterval int `env:"LOCALAI_P2P_OTP_INTERVAL,P2P_OTP_INTERVAL" default:"9000" name:"p2p-otp-interval" help:"Interval for OTP refresh (used during token generation)" group:"p2p"`
Expand Down
36 changes: 25 additions & 11 deletions core/http/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -270,8 +270,31 @@ func API(application *application.Application) (*echo.Echo, error) {
// Enable SPA fallback in the 404 handler for client-side routing
spaFallback = serveIndex

// Serve React SPA at /
e.GET("/", serveIndex)
// Serve React SPA at /app
e.GET("/app", serveIndex)
e.GET("/app/*", serveIndex)

// prefixRedirect performs a redirect that preserves X-Forwarded-Prefix for reverse-proxy support.
prefixRedirect := func(c echo.Context, target string) error {
if prefix := c.Request().Header.Get("X-Forwarded-Prefix"); prefix != "" {
target = strings.TrimSuffix(prefix, "/") + target
}
return c.Redirect(http.StatusMovedPermanently, target)
}

// Redirect / to /app
e.GET("/", func(c echo.Context) error {
return prefixRedirect(c, "/app")
})

// Backward compatibility: redirect /browse/* to /app/*
e.GET("/browse", func(c echo.Context) error {
return prefixRedirect(c, "/app")
})
e.GET("/browse/*", func(c echo.Context) error {
p := c.Param("*")
return prefixRedirect(c, "/app/"+p)
})

// Serve React static assets (JS, CSS, etc.)
serveReactAsset := func(c echo.Context) error {
Expand All @@ -291,15 +314,6 @@ func API(application *application.Application) (*echo.Echo, error) {
return echo.NewHTTPError(http.StatusNotFound)
}
e.GET("/assets/*", serveReactAsset)

// Backward compatibility: redirect /app/* to /*
e.GET("/app", func(c echo.Context) error {
return c.Redirect(http.StatusMovedPermanently, "/")
})
e.GET("/app/*", func(c echo.Context) error {
p := c.Param("*")
return c.Redirect(http.StatusMovedPermanently, "/"+p)
})
}
}
routes.RegisterJINARoutes(e, requestExtractor, application.ModelConfigLoader(), application.ModelLoader(), application.ApplicationConfig())
Expand Down
228 changes: 228 additions & 0 deletions core/http/middleware/auth_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,228 @@
package middleware_test

import (
"net/http"
"net/http/httptest"

"github.com/labstack/echo/v4"
"github.com/mudler/LocalAI/core/config"
. "github.com/mudler/LocalAI/core/http/middleware"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)

// ok is a simple handler that returns 200 OK.
func ok(c echo.Context) error {
return c.String(http.StatusOK, "ok")
}

// newAuthApp creates a minimal Echo app with auth middleware applied.
// Requests that fail auth with Content-Type: application/json get a JSON 401
// (no template renderer needed).
func newAuthApp(appConfig *config.ApplicationConfig) *echo.Echo {
e := echo.New()

mw, err := GetKeyAuthConfig(appConfig)
Expect(err).ToNot(HaveOccurred())
e.Use(mw)

// Sensitive API routes
e.GET("/v1/models", ok)
e.POST("/v1/chat/completions", ok)

// UI routes
e.GET("/app", ok)
e.GET("/app/*", ok)
e.GET("/browse", ok)
e.GET("/browse/*", ok)
e.GET("/login", ok)
e.GET("/explorer", ok)
e.GET("/assets/*", ok)
e.POST("/app", ok)

return e
}

// doRequest performs an HTTP request against the given Echo app and returns the recorder.
func doRequest(e *echo.Echo, method, path string, opts ...func(*http.Request)) *httptest.ResponseRecorder {
req := httptest.NewRequest(method, path, nil)
req.Header.Set("Content-Type", "application/json")
for _, opt := range opts {
opt(req)
}
rec := httptest.NewRecorder()
e.ServeHTTP(rec, req)
return rec
}

func withBearerToken(token string) func(*http.Request) {
return func(req *http.Request) {
req.Header.Set("Authorization", "Bearer "+token)
}
}

func withXApiKey(key string) func(*http.Request) {
return func(req *http.Request) {
req.Header.Set("x-api-key", key)
}
}

func withXiApiKey(key string) func(*http.Request) {
return func(req *http.Request) {
req.Header.Set("xi-api-key", key)
}
}

func withTokenCookie(token string) func(*http.Request) {
return func(req *http.Request) {
req.AddCookie(&http.Cookie{Name: "token", Value: token})
}
}

var _ = Describe("Auth Middleware", func() {

Context("when API keys are configured", func() {
var app *echo.Echo
const validKey = "sk-test-key-123"

BeforeEach(func() {
appConfig := config.NewApplicationConfig()
appConfig.ApiKeys = []string{validKey}
app = newAuthApp(appConfig)
})

It("returns 401 for GET request without a key", func() {
rec := doRequest(app, http.MethodGet, "/v1/models")
Expect(rec.Code).To(Equal(http.StatusUnauthorized))
})

It("returns 401 for POST request without a key", func() {
rec := doRequest(app, http.MethodPost, "/v1/chat/completions")
Expect(rec.Code).To(Equal(http.StatusUnauthorized))
})

It("returns 401 for request with an invalid key", func() {
rec := doRequest(app, http.MethodGet, "/v1/models", withBearerToken("wrong-key"))
Expect(rec.Code).To(Equal(http.StatusUnauthorized))
})

It("passes through with valid Bearer token in Authorization header", func() {
rec := doRequest(app, http.MethodGet, "/v1/models", withBearerToken(validKey))
Expect(rec.Code).To(Equal(http.StatusOK))
})

It("passes through with valid x-api-key header", func() {
rec := doRequest(app, http.MethodGet, "/v1/models", withXApiKey(validKey))
Expect(rec.Code).To(Equal(http.StatusOK))
})

It("passes through with valid xi-api-key header", func() {
rec := doRequest(app, http.MethodGet, "/v1/models", withXiApiKey(validKey))
Expect(rec.Code).To(Equal(http.StatusOK))
})

It("passes through with valid token cookie", func() {
rec := doRequest(app, http.MethodGet, "/v1/models", withTokenCookie(validKey))
Expect(rec.Code).To(Equal(http.StatusOK))
})
})

Context("when no API keys are configured", func() {
var app *echo.Echo

BeforeEach(func() {
appConfig := config.NewApplicationConfig()
app = newAuthApp(appConfig)
})

It("passes through without any key", func() {
rec := doRequest(app, http.MethodGet, "/v1/models")
Expect(rec.Code).To(Equal(http.StatusOK))
})
})

Context("GET exempted endpoints (feature enabled)", func() {
var app *echo.Echo
const validKey = "sk-test-key-456"

BeforeEach(func() {
appConfig := config.NewApplicationConfig(
config.WithApiKeys([]string{validKey}),
config.WithDisableApiKeyRequirementForHttpGet(true),
config.WithHttpGetExemptedEndpoints([]string{
"^/$",
"^/app(/.*)?$",
"^/browse(/.*)?$",
"^/login/?$",
"^/explorer/?$",
"^/assets/.*$",
"^/static/.*$",
"^/swagger.*$",
}),
)
app = newAuthApp(appConfig)
})

It("allows GET to /app without a key", func() {
rec := doRequest(app, http.MethodGet, "/app")
Expect(rec.Code).To(Equal(http.StatusOK))
})

It("allows GET to /app/chat/model sub-route without a key", func() {
rec := doRequest(app, http.MethodGet, "/app/chat/llama3")
Expect(rec.Code).To(Equal(http.StatusOK))
})

It("allows GET to /browse/models without a key", func() {
rec := doRequest(app, http.MethodGet, "/browse/models")
Expect(rec.Code).To(Equal(http.StatusOK))
})

It("allows GET to /login without a key", func() {
rec := doRequest(app, http.MethodGet, "/login")
Expect(rec.Code).To(Equal(http.StatusOK))
})

It("allows GET to /explorer without a key", func() {
rec := doRequest(app, http.MethodGet, "/explorer")
Expect(rec.Code).To(Equal(http.StatusOK))
})

It("allows GET to /assets/main.js without a key", func() {
rec := doRequest(app, http.MethodGet, "/assets/main.js")
Expect(rec.Code).To(Equal(http.StatusOK))
})

It("rejects POST to /app without a key", func() {
rec := doRequest(app, http.MethodPost, "/app")
Expect(rec.Code).To(Equal(http.StatusUnauthorized))
})

It("rejects GET to /v1/models without a key", func() {
rec := doRequest(app, http.MethodGet, "/v1/models")
Expect(rec.Code).To(Equal(http.StatusUnauthorized))
})
})

Context("GET exempted endpoints (feature disabled)", func() {
var app *echo.Echo
const validKey = "sk-test-key-789"

BeforeEach(func() {
appConfig := config.NewApplicationConfig(
config.WithApiKeys([]string{validKey}),
// DisableApiKeyRequirementForHttpGet defaults to false
config.WithHttpGetExemptedEndpoints([]string{
"^/$",
"^/app(/.*)?$",
}),
)
app = newAuthApp(appConfig)
})

It("requires auth for GET to /app even though it matches exempted pattern", func() {
rec := doRequest(app, http.MethodGet, "/app")
Expect(rec.Code).To(Equal(http.StatusUnauthorized))
})
})
})
2 changes: 1 addition & 1 deletion core/http/react-ui/src/App.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ export default function App() {
const { toasts, addToast, removeToast } = useToast()
const [version, setVersion] = useState('')
const location = useLocation()
const isChatRoute = location.pathname.startsWith('/chat') || location.pathname.match(/^\/agents\/[^/]+\/chat/)
const isChatRoute = location.pathname.match(/\/chat(\/|$)/) || location.pathname.match(/\/agents\/[^/]+\/chat/)

useEffect(() => {
systemApi.version()
Expand Down
3 changes: 2 additions & 1 deletion core/http/react-ui/src/components/ResourceCards.jsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { useState } from 'react'
import { getArtifactIcon, inferMetadataType } from '../utils/artifacts'
import { apiUrl } from '../utils/basePath'

export default function ResourceCards({ metadata, onOpenArtifact, messageIndex, agentName }) {
const [expanded, setExpanded] = useState(false)
Expand All @@ -9,7 +10,7 @@ export default function ResourceCards({ metadata, onOpenArtifact, messageIndex,
const items = []
const fileUrl = (absPath) => {
if (!agentName) return absPath
return `/api/agents/${encodeURIComponent(agentName)}/files?path=${encodeURIComponent(absPath)}`
return apiUrl(`/api/agents/${encodeURIComponent(agentName)}/files?path=${encodeURIComponent(absPath)}`)
}

Object.entries(metadata).forEach(([key, values]) => {
Expand Down
Loading
Loading