Skip to content

Commit ed2c6da

Browse files
authored
fix(ui): Move routes to /app to avoid conflict with API endpoints (#8978)
Also test for regressions in HTTP GET API key exempted endpoints because this list can get out of sync with the UI routes. Also fix support for proxying on a different prefix both server and client side. Signed-off-by: Richard Palethorpe <io@richiejp.com>
1 parent f9a850c commit ed2c6da

34 files changed

Lines changed: 468 additions & 171 deletions

core/cli/run.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,7 @@ type RunCMD struct {
6868
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"`
6969
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"`
7070
DisableMetricsEndpoint bool `env:"LOCALAI_DISABLE_METRICS_ENDPOINT,DISABLE_METRICS_ENDPOINT" default:"false" help:"Disable the /metrics endpoint" group:"api"`
71-
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"`
71+
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"`
7272
Peer2Peer bool `env:"LOCALAI_P2P,P2P" name:"p2p" default:"false" help:"Enable P2P mode" group:"p2p"`
7373
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"`
7474
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"`

core/http/app.go

Lines changed: 25 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -270,8 +270,31 @@ func API(application *application.Application) (*echo.Echo, error) {
270270
// Enable SPA fallback in the 404 handler for client-side routing
271271
spaFallback = serveIndex
272272

273-
// Serve React SPA at /
274-
e.GET("/", serveIndex)
273+
// Serve React SPA at /app
274+
e.GET("/app", serveIndex)
275+
e.GET("/app/*", serveIndex)
276+
277+
// prefixRedirect performs a redirect that preserves X-Forwarded-Prefix for reverse-proxy support.
278+
prefixRedirect := func(c echo.Context, target string) error {
279+
if prefix := c.Request().Header.Get("X-Forwarded-Prefix"); prefix != "" {
280+
target = strings.TrimSuffix(prefix, "/") + target
281+
}
282+
return c.Redirect(http.StatusMovedPermanently, target)
283+
}
284+
285+
// Redirect / to /app
286+
e.GET("/", func(c echo.Context) error {
287+
return prefixRedirect(c, "/app")
288+
})
289+
290+
// Backward compatibility: redirect /browse/* to /app/*
291+
e.GET("/browse", func(c echo.Context) error {
292+
return prefixRedirect(c, "/app")
293+
})
294+
e.GET("/browse/*", func(c echo.Context) error {
295+
p := c.Param("*")
296+
return prefixRedirect(c, "/app/"+p)
297+
})
275298

276299
// Serve React static assets (JS, CSS, etc.)
277300
serveReactAsset := func(c echo.Context) error {
@@ -291,15 +314,6 @@ func API(application *application.Application) (*echo.Echo, error) {
291314
return echo.NewHTTPError(http.StatusNotFound)
292315
}
293316
e.GET("/assets/*", serveReactAsset)
294-
295-
// Backward compatibility: redirect /app/* to /*
296-
e.GET("/app", func(c echo.Context) error {
297-
return c.Redirect(http.StatusMovedPermanently, "/")
298-
})
299-
e.GET("/app/*", func(c echo.Context) error {
300-
p := c.Param("*")
301-
return c.Redirect(http.StatusMovedPermanently, "/"+p)
302-
})
303317
}
304318
}
305319
routes.RegisterJINARoutes(e, requestExtractor, application.ModelConfigLoader(), application.ModelLoader(), application.ApplicationConfig())

core/http/middleware/auth_test.go

Lines changed: 228 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,228 @@
1+
package middleware_test
2+
3+
import (
4+
"net/http"
5+
"net/http/httptest"
6+
7+
"github.com/labstack/echo/v4"
8+
"github.com/mudler/LocalAI/core/config"
9+
. "github.com/mudler/LocalAI/core/http/middleware"
10+
. "github.com/onsi/ginkgo/v2"
11+
. "github.com/onsi/gomega"
12+
)
13+
14+
// ok is a simple handler that returns 200 OK.
15+
func ok(c echo.Context) error {
16+
return c.String(http.StatusOK, "ok")
17+
}
18+
19+
// newAuthApp creates a minimal Echo app with auth middleware applied.
20+
// Requests that fail auth with Content-Type: application/json get a JSON 401
21+
// (no template renderer needed).
22+
func newAuthApp(appConfig *config.ApplicationConfig) *echo.Echo {
23+
e := echo.New()
24+
25+
mw, err := GetKeyAuthConfig(appConfig)
26+
Expect(err).ToNot(HaveOccurred())
27+
e.Use(mw)
28+
29+
// Sensitive API routes
30+
e.GET("/v1/models", ok)
31+
e.POST("/v1/chat/completions", ok)
32+
33+
// UI routes
34+
e.GET("/app", ok)
35+
e.GET("/app/*", ok)
36+
e.GET("/browse", ok)
37+
e.GET("/browse/*", ok)
38+
e.GET("/login", ok)
39+
e.GET("/explorer", ok)
40+
e.GET("/assets/*", ok)
41+
e.POST("/app", ok)
42+
43+
return e
44+
}
45+
46+
// doRequest performs an HTTP request against the given Echo app and returns the recorder.
47+
func doRequest(e *echo.Echo, method, path string, opts ...func(*http.Request)) *httptest.ResponseRecorder {
48+
req := httptest.NewRequest(method, path, nil)
49+
req.Header.Set("Content-Type", "application/json")
50+
for _, opt := range opts {
51+
opt(req)
52+
}
53+
rec := httptest.NewRecorder()
54+
e.ServeHTTP(rec, req)
55+
return rec
56+
}
57+
58+
func withBearerToken(token string) func(*http.Request) {
59+
return func(req *http.Request) {
60+
req.Header.Set("Authorization", "Bearer "+token)
61+
}
62+
}
63+
64+
func withXApiKey(key string) func(*http.Request) {
65+
return func(req *http.Request) {
66+
req.Header.Set("x-api-key", key)
67+
}
68+
}
69+
70+
func withXiApiKey(key string) func(*http.Request) {
71+
return func(req *http.Request) {
72+
req.Header.Set("xi-api-key", key)
73+
}
74+
}
75+
76+
func withTokenCookie(token string) func(*http.Request) {
77+
return func(req *http.Request) {
78+
req.AddCookie(&http.Cookie{Name: "token", Value: token})
79+
}
80+
}
81+
82+
var _ = Describe("Auth Middleware", func() {
83+
84+
Context("when API keys are configured", func() {
85+
var app *echo.Echo
86+
const validKey = "sk-test-key-123"
87+
88+
BeforeEach(func() {
89+
appConfig := config.NewApplicationConfig()
90+
appConfig.ApiKeys = []string{validKey}
91+
app = newAuthApp(appConfig)
92+
})
93+
94+
It("returns 401 for GET request without a key", func() {
95+
rec := doRequest(app, http.MethodGet, "/v1/models")
96+
Expect(rec.Code).To(Equal(http.StatusUnauthorized))
97+
})
98+
99+
It("returns 401 for POST request without a key", func() {
100+
rec := doRequest(app, http.MethodPost, "/v1/chat/completions")
101+
Expect(rec.Code).To(Equal(http.StatusUnauthorized))
102+
})
103+
104+
It("returns 401 for request with an invalid key", func() {
105+
rec := doRequest(app, http.MethodGet, "/v1/models", withBearerToken("wrong-key"))
106+
Expect(rec.Code).To(Equal(http.StatusUnauthorized))
107+
})
108+
109+
It("passes through with valid Bearer token in Authorization header", func() {
110+
rec := doRequest(app, http.MethodGet, "/v1/models", withBearerToken(validKey))
111+
Expect(rec.Code).To(Equal(http.StatusOK))
112+
})
113+
114+
It("passes through with valid x-api-key header", func() {
115+
rec := doRequest(app, http.MethodGet, "/v1/models", withXApiKey(validKey))
116+
Expect(rec.Code).To(Equal(http.StatusOK))
117+
})
118+
119+
It("passes through with valid xi-api-key header", func() {
120+
rec := doRequest(app, http.MethodGet, "/v1/models", withXiApiKey(validKey))
121+
Expect(rec.Code).To(Equal(http.StatusOK))
122+
})
123+
124+
It("passes through with valid token cookie", func() {
125+
rec := doRequest(app, http.MethodGet, "/v1/models", withTokenCookie(validKey))
126+
Expect(rec.Code).To(Equal(http.StatusOK))
127+
})
128+
})
129+
130+
Context("when no API keys are configured", func() {
131+
var app *echo.Echo
132+
133+
BeforeEach(func() {
134+
appConfig := config.NewApplicationConfig()
135+
app = newAuthApp(appConfig)
136+
})
137+
138+
It("passes through without any key", func() {
139+
rec := doRequest(app, http.MethodGet, "/v1/models")
140+
Expect(rec.Code).To(Equal(http.StatusOK))
141+
})
142+
})
143+
144+
Context("GET exempted endpoints (feature enabled)", func() {
145+
var app *echo.Echo
146+
const validKey = "sk-test-key-456"
147+
148+
BeforeEach(func() {
149+
appConfig := config.NewApplicationConfig(
150+
config.WithApiKeys([]string{validKey}),
151+
config.WithDisableApiKeyRequirementForHttpGet(true),
152+
config.WithHttpGetExemptedEndpoints([]string{
153+
"^/$",
154+
"^/app(/.*)?$",
155+
"^/browse(/.*)?$",
156+
"^/login/?$",
157+
"^/explorer/?$",
158+
"^/assets/.*$",
159+
"^/static/.*$",
160+
"^/swagger.*$",
161+
}),
162+
)
163+
app = newAuthApp(appConfig)
164+
})
165+
166+
It("allows GET to /app without a key", func() {
167+
rec := doRequest(app, http.MethodGet, "/app")
168+
Expect(rec.Code).To(Equal(http.StatusOK))
169+
})
170+
171+
It("allows GET to /app/chat/model sub-route without a key", func() {
172+
rec := doRequest(app, http.MethodGet, "/app/chat/llama3")
173+
Expect(rec.Code).To(Equal(http.StatusOK))
174+
})
175+
176+
It("allows GET to /browse/models without a key", func() {
177+
rec := doRequest(app, http.MethodGet, "/browse/models")
178+
Expect(rec.Code).To(Equal(http.StatusOK))
179+
})
180+
181+
It("allows GET to /login without a key", func() {
182+
rec := doRequest(app, http.MethodGet, "/login")
183+
Expect(rec.Code).To(Equal(http.StatusOK))
184+
})
185+
186+
It("allows GET to /explorer without a key", func() {
187+
rec := doRequest(app, http.MethodGet, "/explorer")
188+
Expect(rec.Code).To(Equal(http.StatusOK))
189+
})
190+
191+
It("allows GET to /assets/main.js without a key", func() {
192+
rec := doRequest(app, http.MethodGet, "/assets/main.js")
193+
Expect(rec.Code).To(Equal(http.StatusOK))
194+
})
195+
196+
It("rejects POST to /app without a key", func() {
197+
rec := doRequest(app, http.MethodPost, "/app")
198+
Expect(rec.Code).To(Equal(http.StatusUnauthorized))
199+
})
200+
201+
It("rejects GET to /v1/models without a key", func() {
202+
rec := doRequest(app, http.MethodGet, "/v1/models")
203+
Expect(rec.Code).To(Equal(http.StatusUnauthorized))
204+
})
205+
})
206+
207+
Context("GET exempted endpoints (feature disabled)", func() {
208+
var app *echo.Echo
209+
const validKey = "sk-test-key-789"
210+
211+
BeforeEach(func() {
212+
appConfig := config.NewApplicationConfig(
213+
config.WithApiKeys([]string{validKey}),
214+
// DisableApiKeyRequirementForHttpGet defaults to false
215+
config.WithHttpGetExemptedEndpoints([]string{
216+
"^/$",
217+
"^/app(/.*)?$",
218+
}),
219+
)
220+
app = newAuthApp(appConfig)
221+
})
222+
223+
It("requires auth for GET to /app even though it matches exempted pattern", func() {
224+
rec := doRequest(app, http.MethodGet, "/app")
225+
Expect(rec.Code).To(Equal(http.StatusUnauthorized))
226+
})
227+
})
228+
})

core/http/react-ui/src/App.jsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ export default function App() {
1515
const { toasts, addToast, removeToast } = useToast()
1616
const [version, setVersion] = useState('')
1717
const location = useLocation()
18-
const isChatRoute = location.pathname.startsWith('/chat') || location.pathname.match(/^\/agents\/[^/]+\/chat/)
18+
const isChatRoute = location.pathname.match(/\/chat(\/|$)/) || location.pathname.match(/\/agents\/[^/]+\/chat/)
1919

2020
useEffect(() => {
2121
systemApi.version()

core/http/react-ui/src/components/ResourceCards.jsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { useState } from 'react'
22
import { getArtifactIcon, inferMetadataType } from '../utils/artifacts'
3+
import { apiUrl } from '../utils/basePath'
34

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

1516
Object.entries(metadata).forEach(([key, values]) => {

0 commit comments

Comments
 (0)