diff --git a/core/http/app.go b/core/http/app.go
index f713fec4da18..99d11bd69c5c 100644
--- a/core/http/app.go
+++ b/core/http/app.go
@@ -443,6 +443,25 @@ func API(application *application.Application) (*echo.Echo, error) {
baseTag := ``
indexHTML = []byte(strings.Replace(string(indexHTML), "
", "\n "+baseTag, 1))
}
+ // only changes how relative URLs resolve; path-absolute
+ // URLs (those starting with `/`) still resolve against the origin
+ // and would bypass the reverse-proxy prefix. Rewrite the internal
+ // path-absolute references emitted by the build so the browser
+ // requests them through the proxy under the prefix.
+ //
+ // HTML-escape the prefix before interpolating it into attributes:
+ // BasePathPrefix already gates X-Forwarded-Prefix via
+ // SafeForwardedPrefix, but the validator only blocks open-redirect
+ // shapes (// prefix, backslashes, control chars), not attribute
+ // breakout characters like `"`. Escaping makes this resilient
+ // even if the validator ever loosens.
+ if prefix := httpMiddleware.BasePathPrefix(c); prefix != "/" {
+ safePrefix := httpMiddleware.SecureBaseHref(prefix)
+ html := string(indexHTML)
+ html = strings.ReplaceAll(html, `="/assets/`, `="`+safePrefix+`assets/`)
+ html = strings.ReplaceAll(html, `="/favicon.svg"`, `="`+safePrefix+`favicon.svg"`)
+ indexHTML = []byte(html)
+ }
return c.HTMLBlob(http.StatusOK, indexHTML)
}
diff --git a/core/http/app_test.go b/core/http/app_test.go
index 31c6c5a5553e..bd7fa501ef63 100644
--- a/core/http/app_test.go
+++ b/core/http/app_test.go
@@ -446,6 +446,42 @@ var _ = Describe("API test", func() {
Expect(sc).To(Equal(200), "status code")
Expect(string(body)).To(ContainSubstring(``), "body")
})
+
+ // Caddy's `handle_path` (and similar directives) strip the matched
+ // prefix before forwarding upstream, so LocalAI receives the
+ // already-stripped path together with X-Forwarded-Prefix. The base
+ // href and asset URLs must still include the prefix so the browser
+ // requests them through the proxy.
+ It("Should support reverse-proxy when prefix is stripped by the proxy", func() {
+
+ err, sc, body := getRequest("http://127.0.0.1:9090/app", http.Header{
+ "X-Forwarded-Proto": {"https"},
+ "X-Forwarded-Host": {"example.org"},
+ "X-Forwarded-Prefix": {"/myprefix"},
+ })
+ Expect(err).To(BeNil(), "error")
+ Expect(sc).To(Equal(200), "status code")
+ Expect(string(body)).To(ContainSubstring(``), "body")
+ Expect(string(body)).ToNot(ContainSubstring(`="/assets/`), "asset URLs must include the prefix")
+ Expect(string(body)).ToNot(ContainSubstring(`="/favicon.svg"`), "favicon URL must include the prefix")
+ })
+
+ // X-Forwarded-Prefix is attacker controllable on misconfigured
+ // proxy chains. A value like "//evil.com" would otherwise turn the
+ // asset URL rewrite into a protocol-relative URL that loads JS
+ // from a foreign origin. BasePathPrefix must reject these via
+ // SafeForwardedPrefix and fall back to "/".
+ It("Should ignore an unsafe X-Forwarded-Prefix and not poison asset URLs", func() {
+ err, sc, body := getRequest("http://127.0.0.1:9090/app", http.Header{
+ "X-Forwarded-Proto": {"https"},
+ "X-Forwarded-Host": {"example.org"},
+ "X-Forwarded-Prefix": {"//evil.com"},
+ })
+ Expect(err).To(BeNil(), "error")
+ Expect(sc).To(Equal(200), "status code")
+ Expect(string(body)).ToNot(ContainSubstring("evil.com"), "unsafe prefix must not leak into the response")
+ Expect(string(body)).ToNot(ContainSubstring(`="//`), "asset URLs must not become protocol-relative")
+ })
})
Context("Applying models", func() {
diff --git a/core/http/middleware/baseurl.go b/core/http/middleware/baseurl.go
index 78a59289a81f..a1e1844aebda 100644
--- a/core/http/middleware/baseurl.go
+++ b/core/http/middleware/baseurl.go
@@ -6,20 +6,55 @@ import (
"github.com/labstack/echo/v4"
)
-// BaseURL returns the base URL for the given HTTP request context.
-// It takes into account that the app may be exposed by a reverse-proxy under a different protocol, host and path.
-// The returned URL is guaranteed to end with `/`.
-// The method should be used in conjunction with the StripPathPrefix middleware.
-func BaseURL(c echo.Context) string {
+// BasePathPrefix returns the URL path prefix that the request was reached
+// under (e.g. "/myprefix/"). It always returns a value that starts and ends
+// with `/`, defaulting to "/" when the app is not behind a path prefix.
+//
+// It first looks at the path StripPathPrefix removed (when the proxy forwards
+// the prefix in the URL), then falls back to the X-Forwarded-Prefix header
+// (when the proxy strips the prefix before forwarding, e.g. Caddy's
+// handle_path).
+//
+// The header fallback is gated through SafeForwardedPrefix because the value
+// flows into the SPA HTML response (both and the path-absolute
+// asset URL rewrite in serveIndex). X-Forwarded-Prefix is attacker
+// controllable on misconfigured proxy chains; without that gate a value like
+// "//evil.com" turns the asset rewrite into a protocol-relative URL that
+// loads JS from a foreign origin.
+func BasePathPrefix(c echo.Context) string {
path := c.Path()
origPath := c.Request().URL.Path
- // Check if StripPathPrefix middleware stored the original path
if storedPath, ok := c.Get("_original_path").(string); ok && storedPath != "" {
origPath = storedPath
}
- // Check X-Forwarded-Proto for scheme
+ if path != origPath && strings.HasSuffix(origPath, path) && len(path) > 0 {
+ prefixLen := len(origPath) - len(path)
+ if prefixLen > 0 {
+ pathPrefix := origPath[:prefixLen]
+ if !strings.HasSuffix(pathPrefix, "/") {
+ pathPrefix += "/"
+ }
+ return pathPrefix
+ }
+ }
+
+ if validated, ok := SafeForwardedPrefix(c.Request().Header.Get("X-Forwarded-Prefix")); ok {
+ if !strings.HasSuffix(validated, "/") {
+ validated += "/"
+ }
+ return validated
+ }
+
+ return "/"
+}
+
+// BaseURL returns the base URL for the given HTTP request context.
+// It takes into account that the app may be exposed by a reverse-proxy under a different protocol, host and path.
+// The returned URL is guaranteed to end with `/`.
+// The method should be used in conjunction with the StripPathPrefix middleware.
+func BaseURL(c echo.Context) string {
scheme := "http"
if c.Request().Header.Get("X-Forwarded-Proto") == "https" {
scheme = "https"
@@ -27,22 +62,10 @@ func BaseURL(c echo.Context) string {
scheme = "https"
}
- // Check X-Forwarded-Host for host
host := c.Request().Host
if forwardedHost := c.Request().Header.Get("X-Forwarded-Host"); forwardedHost != "" {
host = forwardedHost
}
- if path != origPath && strings.HasSuffix(origPath, path) && len(path) > 0 {
- prefixLen := len(origPath) - len(path)
- if prefixLen > 0 && prefixLen <= len(origPath) {
- pathPrefix := origPath[:prefixLen]
- if !strings.HasSuffix(pathPrefix, "/") {
- pathPrefix += "/"
- }
- return scheme + "://" + host + pathPrefix
- }
- }
-
- return scheme + "://" + host + "/"
+ return scheme + "://" + host + BasePathPrefix(c)
}
diff --git a/core/http/middleware/baseurl_test.go b/core/http/middleware/baseurl_test.go
index b0770b8eae41..4f6dbb1d1ed9 100644
--- a/core/http/middleware/baseurl_test.go
+++ b/core/http/middleware/baseurl_test.go
@@ -55,4 +55,84 @@ var _ = Describe("BaseURL", func() {
Expect(actualURL).To(Equal("http://example.com/myprefix/"), "base URL")
})
})
+
+ // Caddy's handle_path (and similar reverse-proxy directives) strips the
+ // matched prefix before forwarding upstream, so LocalAI receives the
+ // already-stripped path together with X-Forwarded-Prefix. In that case
+ // StripPathPrefix never stores _original_path, but BaseURL must still
+ // honor the header so that and asset URLs include the prefix.
+ Context("with X-Forwarded-Prefix header but pre-stripped path", func() {
+ It("should return base URL with prefix from header", func() {
+ app := echo.New()
+ actualURL := ""
+
+ routePath := "/app"
+ app.GET(routePath, func(c echo.Context) error {
+ actualURL = BaseURL(c)
+ return nil
+ })
+
+ req := httptest.NewRequest("GET", "/app", nil)
+ req.Header.Set("X-Forwarded-Prefix", "/localai")
+ rec := httptest.NewRecorder()
+ app.ServeHTTP(rec, req)
+
+ Expect(rec.Code).To(Equal(200), "response status code")
+ Expect(actualURL).To(Equal("http://example.com/localai/"), "base URL")
+ })
+
+ It("should normalize a prefix that already ends with a slash", func() {
+ app := echo.New()
+ actualURL := ""
+
+ routePath := "/app"
+ app.GET(routePath, func(c echo.Context) error {
+ actualURL = BaseURL(c)
+ return nil
+ })
+
+ req := httptest.NewRequest("GET", "/app", nil)
+ req.Header.Set("X-Forwarded-Prefix", "/localai/")
+ rec := httptest.NewRecorder()
+ app.ServeHTTP(rec, req)
+
+ Expect(rec.Code).To(Equal(200), "response status code")
+ Expect(actualURL).To(Equal("http://example.com/localai/"), "base URL")
+ })
+ })
+
+ // X-Forwarded-Prefix is attacker controllable on misconfigured proxy
+ // chains, and the value flows into the SPA HTML response (
+ // and asset URLs). BasePathPrefix must gate the header through
+ // SafeForwardedPrefix so values that turn the prefix into an open
+ // redirect or a protocol-relative URL are ignored and the base falls
+ // back to "/".
+ Context("with unsafe X-Forwarded-Prefix header", func() {
+ DescribeTable("falls back to / when the header is unsafe",
+ func(header string) {
+ app := echo.New()
+ actualURL := ""
+
+ app.GET("/app", func(c echo.Context) error {
+ actualURL = BaseURL(c)
+ return nil
+ })
+
+ req := httptest.NewRequest("GET", "/app", nil)
+ req.Header.Set("X-Forwarded-Prefix", header)
+ rec := httptest.NewRecorder()
+ app.ServeHTTP(rec, req)
+
+ Expect(rec.Code).To(Equal(200), "response status code")
+ Expect(actualURL).To(Equal("http://example.com/"), "base URL")
+ },
+ Entry("protocol-relative URL", "//evil.com"),
+ Entry("protocol-relative URL with path", "//evil.com/assets"),
+ Entry("backslash path", `/foo\bar`),
+ Entry("embedded NUL", "/foo\x00bar"),
+ Entry("CR injection", "/foo\rbar"),
+ Entry("LF injection", "/foo\nbar"),
+ Entry("missing leading slash", "evil"),
+ )
+ })
})